feat(web): folder-sync checkboxes + per-account mapping API client

This commit is contained in:
2026-07-03 11:55:40 +07:00
parent bdb3b72f38
commit 1ba7b54e05
3 changed files with 96 additions and 6 deletions
+16
View File
@@ -33,6 +33,8 @@ export interface Account {
skipped: number skipped: number
errors: number errors: number
last_error?: string last_error?: string
folder_mapping?: Record<string, string>
excluded_folders?: string[]
} }
export interface TaskDetail { export interface TaskDetail {
@@ -104,6 +106,20 @@ export const probeFolders = (
export const setFolderMapping = (taskId: number, mapping: Record<string, string>) => export const setFolderMapping = (taskId: number, mapping: Record<string, string>) =>
api(`/api/tasks/${taskId}/folder-mapping`, { ...jsonBody({ mapping }), method: 'PUT' }) api(`/api/tasks/${taskId}/folder-mapping`, { ...jsonBody({ mapping }), method: 'PUT' })
export const probeAccountFolders = (taskId: number, accId: number) =>
api<ProbeResult>(`/api/tasks/${taskId}/accounts/${accId}/probe`, { method: 'POST' })
export const setAccountFolderMapping = (
taskId: number,
accId: number,
mapping: Record<string, string>,
excluded: string[],
) =>
api(`/api/tasks/${taskId}/accounts/${accId}/folder-mapping`, {
...jsonBody({ mapping, excluded }),
method: 'PUT',
})
export const listTasks = () => api<Task[]>('/api/tasks') export const listTasks = () => api<Task[]>('/api/tasks')
export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`) export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`)
+37 -1
View File
@@ -368,11 +368,47 @@
.map-row { .map-row {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr auto; grid-template-columns: auto 1fr auto 1fr auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.map-row-off .map-src,
.map-row-off .map-arrow {
color: var(--fg-faint);
text-decoration: line-through;
}
.map-check {
display: flex;
align-items: center;
}
.map-check input,
.map-all input {
accent-color: var(--accent);
cursor: pointer;
}
.map-all {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
cursor: pointer;
}
.map-excluded {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
}
.map-src { .map-src {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 13px;
+43 -5
View File
@@ -6,7 +6,8 @@ type Props = {
srcFolders: string[] srcFolders: string[]
dstFolders: string[] dstFolders: string[]
initialMapping: Record<string, string> initialMapping: Record<string, string>
onConfirm: (mapping: Record<string, string>) => void initialExcluded: string[]
onConfirm: (mapping: Record<string, string>, excluded: string[]) => void
onCancel: () => void onCancel: () => void
} }
@@ -18,8 +19,14 @@ function defaultDst(src: string, dstFolders: string[], initial: Record<string, s
return src return src
} }
export function FolderMappingModal({ open, srcFolders, dstFolders, initialMapping, onConfirm, onCancel }: Props) { export function FolderMappingModal({
open, srcFolders, dstFolders, initialMapping, initialExcluded, onConfirm, onCancel,
}: Props) {
const [choice, setChoice] = useState<Record<string, string>>({}) const [choice, setChoice] = useState<Record<string, string>>({})
const [synced, setSynced] = useState<Record<string, boolean>>(() => {
const excl = new Set(initialExcluded)
return Object.fromEntries(srcFolders.map((f) => [f, !excl.has(f)]))
})
// Options per select: all destination folders, plus the source name itself // Options per select: all destination folders, plus the source name itself
// (marked "create") when it does not already exist on the destination. // (marked "create") when it does not already exist on the destination.
@@ -36,12 +43,18 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin
function confirm() { function confirm() {
const mapping: Record<string, string> = { ...initialMapping } const mapping: Record<string, string> = { ...initialMapping }
const excluded: string[] = []
for (const src of srcFolders) { for (const src of srcFolders) {
if (synced[src] === false) {
excluded.push(src)
delete mapping[src]
continue
}
const dst = valueFor(src) const dst = valueFor(src)
if (dst === src) delete mapping[src] if (dst === src) delete mapping[src]
else mapping[src] = dst else mapping[src] = dst
} }
onConfirm(mapping) onConfirm(mapping, excluded)
} }
return ( return (
@@ -51,12 +64,32 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin
Route each source folder to an existing destination folder. Leaving a folder mapped to its own name Route each source folder to an existing destination folder. Leaving a folder mapped to its own name
creates it on the destination if missing (e.g. map <code>Спам</code> <code>Spam</code> to avoid duplicates). creates it on the destination if missing (e.g. map <code>Спам</code> <code>Spam</code> to avoid duplicates).
</p> </p>
<label className="map-all">
<input
type="checkbox"
checked={srcFolders.every((f) => synced[f] !== false)}
onChange={(e) => {
const on = e.target.checked
setSynced(Object.fromEntries(srcFolders.map((f) => [f, on])))
}}
/>
sync all folders
</label>
<div className="map-grid"> <div className="map-grid">
{srcFolders.map((src) => { {srcFolders.map((src) => {
const on = synced[src] !== false
const val = valueFor(src) const val = valueFor(src)
const creates = !dstFolders.includes(val) const creates = !dstFolders.includes(val)
return ( return (
<div className="map-row" key={src}> <div className={`map-row${on ? '' : ' map-row-off'}`} key={src}>
<label className="map-check">
<input
type="checkbox"
checked={on}
aria-label={`Sync folder ${src}`}
onChange={(e) => setSynced((s) => ({ ...s, [src]: e.target.checked }))}
/>
</label>
<span className="map-src" title={src}> <span className="map-src" title={src}>
{src} {src}
</span> </span>
@@ -65,6 +98,7 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin
className="map-select" className="map-select"
aria-label={`Destination folder for ${src}`} aria-label={`Destination folder for ${src}`}
value={val} value={val}
disabled={!on}
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))} onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
> >
{options(src).map((f) => ( {options(src).map((f) => (
@@ -74,7 +108,11 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin
</option> </option>
))} ))}
</select> </select>
{creates && <span className="map-new">new</span>} {on ? (
creates && <span className="map-new">new</span>
) : (
<span className="map-excluded">excluded</span>
)}
</div> </div>
) )
})} })}