From 7b686914137c3e014b5400915bf070ded8e42b79 Mon Sep 17 00:00:00 2001 From: hiroaki222 Date: Fri, 14 Aug 2026 06:45:01 +0900 Subject: [PATCH 1/3] chore: initial commit From 2404cd19a44ca7c9bed6c1f2039217ee06c3f38f Mon Sep 17 00:00:00 2001 From: hiroaki222 Date: Fri, 14 Aug 2026 06:45:28 +0900 Subject: [PATCH 2/3] feat(gui): let tracks be taken back out of the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop is usually a whole folder, and there was no way to convert all of it but one. The list was whatever had been handed over, until the window was given something else. Each row now carries a cross, kept faint until the row is under the pointer: it is the one control here that throws work away, and a column of them down a list of two hundred would read as the point of the screen rather than as an escape from it. Its click stops there, because the row already opens on click and one press should not answer twice. Beside the convert button there is a clear, outlined in red rather than filled. The orange one next to it is what people came to press, and two solid blocks of the same size would make the destructive one just as easy to hit by accident. It fills only under the pointer. Nothing on disk is touched either way — this empties a list. Taking a track out rewrites the list to what is left, rather than filtering on the way past. The folder behind a drop is re-read whenever a setting changes, which would otherwise bring back everything ever removed. Neither control is live during a run, when what is being converted was settled before the button was pressed. --- gui/src/App.tsx | 36 ++++++++++++++++++++ gui/src/components/TrackRow.tsx | 30 ++++++++++++++++- gui/src/strings/en.ts | 2 ++ gui/src/strings/ja.ts | 2 ++ gui/src/styles.css | 59 +++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c7aac91..ff48ae9 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -183,6 +183,32 @@ function Window({ choice, onChooseLanguage }: WindowProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile, keepComment, artwork, chosen]); + /* + The list is rewritten to what is left rather than filtered on the way out. + A drop is usually one folder, and that folder is re-read whenever a setting + changes — which would bring back everything ever taken out of the list. + */ + function remove(path: string) { + const left = tracks.filter((track) => track.path !== path); + + setTracks(left); + setDropped(left.map((track) => track.path)); + if (selected === path) setSelected(null); + } + + /* + Nothing on disk is touched — this empties the list and no more. It is marked + as the destructive one because it is the only control here that throws away + work already done, and it sits beside the one that acts on all of it. + */ + function clear() { + setTracks([]); + setDropped([]); + setSelected(null); + setOutcomes(null); + setFailure(null); + } + async function choose() { const picked = await open({ multiple: true, title: t.dialog.pickTracks }); if (picked === null) return; @@ -281,6 +307,14 @@ function Window({ choice, onChooseLanguage }: WindowProps) { + {track.error !== null &&
{track.error}
} diff --git a/gui/src/strings/en.ts b/gui/src/strings/en.ts index d6154ba..3c55eab 100644 --- a/gui/src/strings/en.ts +++ b/gui/src/strings/en.ts @@ -61,6 +61,7 @@ export const en: Strings = { keepComment: "Keep comments", keepArtwork: "Keep artwork", pick: "Choose tracks", + clear: "Clear", convert: (count) => `Convert ${count}`, }, @@ -83,6 +84,7 @@ export const en: Strings = { track: { unreadable: "Cannot be read", + remove: "Take out of the list", dither: "dithered", lampsNow: "NOW", lampsAfter: "AFTER", diff --git a/gui/src/strings/ja.ts b/gui/src/strings/ja.ts index 3b6c264..1ea1a82 100644 --- a/gui/src/strings/ja.ts +++ b/gui/src/strings/ja.ts @@ -52,6 +52,7 @@ export const ja = { keepComment: "コメントを残す", keepArtwork: "ジャケットを残す", pick: "曲を選ぶ", + clear: "すべて外す", convert: (count: number) => `${count}曲を変換`, }, @@ -74,6 +75,7 @@ export const ja = { track: { unreadable: "読み込めません", + remove: "リストから外す", dither: "ディザ", lampsNow: "NOW", lampsAfter: "変換後", diff --git a/gui/src/styles.css b/gui/src/styles.css index 7f2d360..f3c7d05 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -235,6 +235,28 @@ select { border: 1px solid var(--orange); } +/* + Outlined rather than filled: the orange one beside it is the button people + came here to press, and two solid blocks competing at the same size would + make the destructive one just as easy to hit by accident. It fills only once + the pointer is on it, which is late enough to be a warning and not a wall. +*/ +.danger-btn { + padding: 7px 14px; + font-family: var(--jp); + font-size: 12.5px; + font-weight: 700; + color: var(--red); + background: transparent; + border: 1px solid var(--red); +} + +.danger-btn:hover:not(:disabled) { + color: #1a0402; + background: var(--red); +} + +.danger-btn:disabled, .go-btn:disabled { color: var(--w-3); background: transparent; @@ -448,6 +470,43 @@ select { color: var(--green); } +/* + Kept faint until the row is under the pointer. It is the one control here + that throws work away, and a row of them down the list would read as the + point of the screen rather than an escape from it. +*/ +.row-drop { + margin-left: auto; + align-self: center; + width: 22px; + height: 22px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + line-height: 1; + color: var(--w-3); + background: transparent; + border: 1px solid transparent; + opacity: 0; +} + +.row:hover .row-drop, +.row-drop:focus-visible { + opacity: 1; +} + +.row-drop:hover { + color: var(--red); + border-color: var(--red); +} + +.row-drop:disabled { + opacity: 0; + cursor: default; +} + .row[data-sel] .row-judge.ng { color: #ffd6d4; } From ef75b021d420fa764379273f1adc2bae898fca34 Mon Sep 17 00:00:00 2001 From: hiroaki222 Date: Fri, 14 Aug 2026 06:46:52 +0900 Subject: [PATCH 3/3] feat(gui): ask before clearing the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear is the one control that throws away work with a single press, and red alone is a warning nobody reads twice. It now asks. The cancel comes first and holds focus. A dialog that opens with the destructive button under the return key is one that gets dismissed by reflex into the thing it was asking about. Escape and the ground behind it both cancel. Neither button says OK. One says how many tracks leave the list, the other says that no file is deleted — which is the question somebody actually has at that moment. --- gui/src/App.tsx | 15 +++++++- gui/src/components/Confirm.tsx | 64 ++++++++++++++++++++++++++++++++++ gui/src/strings/en.ts | 8 +++++ gui/src/strings/ja.ts | 8 +++++ gui/src/styles.css | 46 ++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 gui/src/components/Confirm.tsx diff --git a/gui/src/App.tsx b/gui/src/App.tsx index ff48ae9..cd39f87 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -20,6 +20,7 @@ import { locale as loadLocale, tools as loadTools, } from "./api"; +import { Confirm } from "./components/Confirm"; import { DeviceTable } from "./components/DeviceTable"; import { DevicePicker } from "./components/DevicePicker"; import { DrivePanel } from "./components/DrivePanel"; @@ -99,6 +100,7 @@ function Window({ choice, onChooseLanguage }: WindowProps) { const [rows, setRows] = useState([]); const [tools, setTools] = useState(null); const [hovering, setHovering] = useState(false); + const [asking, setAsking] = useState(false); const settings: ConvertOptions = useMemo( () => ({ profile, keepComment, artwork, devices: chosen }), @@ -202,6 +204,7 @@ function Window({ choice, onChooseLanguage }: WindowProps) { work already done, and it sits beside the one that acts on all of it. */ function clear() { + setAsking(false); setTracks([]); setDropped([]); setSelected(null); @@ -310,7 +313,7 @@ function Window({ choice, onChooseLanguage }: WindowProps) { + + + + + ); +} diff --git a/gui/src/strings/en.ts b/gui/src/strings/en.ts index 3c55eab..a26dc0d 100644 --- a/gui/src/strings/en.ts +++ b/gui/src/strings/en.ts @@ -65,6 +65,14 @@ export const en: Strings = { convert: (count) => `Convert ${count}`, }, + confirm: { + cancel: "Keep them", + clearTitle: "Take every track out of the list?", + clearNote: (count) => + `${count} tracks leave the list. Not one file is deleted.`, + clearGo: "Clear the list", + }, + dialog: { pickTracks: "Choose tracks or a folder", pickDrive: "Choose a drive", diff --git a/gui/src/strings/ja.ts b/gui/src/strings/ja.ts index 1ea1a82..d699ae8 100644 --- a/gui/src/strings/ja.ts +++ b/gui/src/strings/ja.ts @@ -56,6 +56,14 @@ export const ja = { convert: (count: number) => `${count}曲を変換`, }, + confirm: { + cancel: "やめる", + clearTitle: "リストの曲をすべて外しますか", + clearNote: (count: number) => + `${count}曲がリストから消えます。ファイル自体は消えません。`, + clearGo: "すべて外す", + }, + dialog: { pickTracks: "曲またはフォルダを選択", pickDrive: "USBを選択", diff --git a/gui/src/styles.css b/gui/src/styles.css index f3c7d05..cf2595c 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -283,6 +283,52 @@ select { font-size: 12.5px; } +/* ---- asking before something is thrown away ---- */ +.veil { + position: fixed; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.72); +} + +.ask { + width: min(420px, 100%); + padding: 20px; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6); +} + +.ask-title { + margin: 0; + font-size: 16px; + font-weight: 700; + word-break: auto-phrase; +} + +.ask-note { + margin: 8px 0 0; + font-size: 12.5px; + color: var(--w-2); + word-break: auto-phrase; +} + +/* + Cancel first, and it is the one holding focus. A dialog that opens with the + destructive button under the return key gets dismissed by reflex into the + very thing it was asking about. +*/ +.ask-answers { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + /* ---- empty state ---- */ .empty { margin: auto;