the path gets the row, the actions get their own
Root location is two states now. reading, it is a path and one Edit. editing, it is a field and the things you can do to it. the actions moved to the block's BOTTOM row, off the value's line. an absolute path is long, and it was being truncated to make room for buttons that are not needed most of the time — the thing somebody opened this to read was the thing squeezed. shown, it wraps with `break-all` rather than truncating, because a path is read from both ends: the machine at the front, the folder at the back, and hiding either is worse than two lines. Edit is a mode and not an always-live field. this is looked at far more often than it is changed, and a live input invites a stray keystroke into a path everything else will depend on. Cancel restores what is stored, so leaving the mode cannot leave half a path on screen looking like the setting. "back to the default" is gone, as asked — and `clearRootPath` and its DELETE route with it, since that button was the only caller. an unreferenced endpoint is the same rot as an unreferenced component, and this one was a second, quieter Save that wrote a value nobody chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-6
@@ -2,7 +2,7 @@ import type { Context } from 'hono';
|
||||
import { createRouter, type HonoVariables } from '@@/create-router';
|
||||
import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
|
||||
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
|
||||
import { readSettings, saveRootPath, clearRootPath } from './settings';
|
||||
import { readSettings, saveRootPath } from './settings';
|
||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||
|
||||
/**
|
||||
@@ -180,11 +180,6 @@ router.put('/_config/root', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/_config/root', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json(await clearRootPath(user.email));
|
||||
});
|
||||
|
||||
/**
|
||||
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
|
||||
*
|
||||
|
||||
+3
-13
@@ -14,7 +14,9 @@ import { fofosAdminSettings } from '../db/schema';
|
||||
* restore onto a machine with a different owner then points at a directory that does not exist while
|
||||
* looking deliberately chosen. Computed, it follows the machine; written, it follows the install.
|
||||
*
|
||||
* A saved path is a DECISION and is stored exactly as given. Only the absence of one is derived.
|
||||
* A saved path is a DECISION and is stored exactly as given. Only the absence of one is derived — and
|
||||
* once saved it stays saved: there is no route back to the default, because "reset this to something
|
||||
* you never chose" is not a thing anybody asked for and it read as a second, quieter Save.
|
||||
*
|
||||
* ── Absolute, always ──
|
||||
*
|
||||
@@ -81,15 +83,3 @@ export async function saveRootPath(input: string, email: string): Promise<Settin
|
||||
|
||||
return readSettings(email);
|
||||
}
|
||||
|
||||
/** Back to the computed default. The row stays, so this is a reset rather than a delete. */
|
||||
export async function clearRootPath(email: string): Promise<SettingsView> {
|
||||
const existing = await row();
|
||||
if (existing) {
|
||||
await db
|
||||
.update(fofosAdminSettings)
|
||||
.set({ rootPath: null, updatedAt: new Date() })
|
||||
.where(eq(fofosAdminSettings.id, existing.id));
|
||||
}
|
||||
return readSettings(email);
|
||||
}
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.29.07',
|
||||
version: '2026.08.29.08',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Fofos',
|
||||
@@ -52,7 +52,7 @@ export const manifest: PluginManifest = {
|
||||
{
|
||||
key: 'fofos-admin',
|
||||
label: 'Fofos',
|
||||
description: "Edit the fofos website — pages, galleries, drops, collabs and the messages people send",
|
||||
description: 'Edit the fofos website — pages, galleries, drops, collabs and the messages people send',
|
||||
|
||||
// Where the fofos URL and token are set, and the probe that checks them. Prefix-matched, so
|
||||
// `/_config/test` is covered too — deliberately, because that request carries the token in its
|
||||
|
||||
+59
-31
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { Check, Loader2, Pencil } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -21,6 +21,17 @@ import { DirectoryPicker } from './DirectoryPicker';
|
||||
* and picking a folder never commits anything by itself — the path is visible and editable before it
|
||||
* is stored. It also means a location OUTSIDE the owner's home can still be typed, which the picker
|
||||
* cannot reach.
|
||||
*
|
||||
* ── Two states, and the actions are on their own row ──
|
||||
*
|
||||
* Reading, it is a path and one Edit. Editing, it is a field and the things you can do to it. The
|
||||
* actions sit on the block's BOTTOM row rather than beside the value, so the path gets the full width
|
||||
* — it is long, absolute and the thing somebody came here to read, and it was being truncated to make
|
||||
* room for buttons that are not needed most of the time.
|
||||
*
|
||||
* Edit is a mode rather than an always-live field because this is a setting that is looked at far more
|
||||
* often than it is changed, and a live input invites a stray keystroke into a path that everything
|
||||
* else depends on.
|
||||
*/
|
||||
|
||||
type Settings = { rootPath: string; isSet: boolean };
|
||||
@@ -32,6 +43,7 @@ export const RootLocationBlock = () => {
|
||||
|
||||
const [saved, setSaved] = useState<Settings | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -62,20 +74,8 @@ export const RootLocationBlock = () => {
|
||||
// From the server, not from what was typed — `~` is expanded and trailing slashes go, so the
|
||||
// field must show what was actually stored rather than what was sent.
|
||||
setValue(result.rootPath);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await clientRef.current.delete<Settings>('/fofos-admin/_config/root');
|
||||
setSaved(result);
|
||||
setValue(result.rootPath);
|
||||
setEditing(false);
|
||||
setPicking(false);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
@@ -93,32 +93,36 @@ export const RootLocationBlock = () => {
|
||||
|
||||
const dirty = value.trim() !== saved.rootPath;
|
||||
|
||||
const stopEditing = () => {
|
||||
// Back to what is stored, so leaving the mode cannot leave a half-typed path on screen looking
|
||||
// like the setting.
|
||||
setValue(saved.rootPath);
|
||||
setEditing(false);
|
||||
setPicking(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{editing ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="/home/you/fofos"
|
||||
className="min-w-0 flex-1 font-mono text-xs"
|
||||
className="w-full font-mono text-xs"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
|
||||
{picking ? 'Close' : 'Browse'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty || !value.trim()}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
{saved.isSet && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void reset()} disabled={busy} title="Back to the default">
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// `break-all`, not `truncate`. An absolute path is read from BOTH ends — the machine at the
|
||||
// front and the folder at the back — so hiding either one is worse than wrapping.
|
||||
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
|
||||
{saved.rootPath}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{picking && (
|
||||
{editing && picking && (
|
||||
<DirectoryPicker
|
||||
onCancel={() => setPicking(false)}
|
||||
onPick={(absolutePath) => {
|
||||
@@ -135,6 +139,30 @@ export const RootLocationBlock = () => {
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'}
|
||||
</p>
|
||||
|
||||
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
|
||||
gets the full width. */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
|
||||
{picking ? 'Close browser' : 'Browse'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty || !value.trim()}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={stopEditing} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user