feat(web): folder-sync checkboxes + per-account mapping API client
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user