Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -99,6 +100,7 @@ function Window({ choice, onChooseLanguage }: WindowProps) {
const [rows, setRows] = useState<DeviceRow[]>([]);
const [tools, setTools] = useState<Tools | null>(null);
const [hovering, setHovering] = useState(false);
const [asking, setAsking] = useState(false);

const settings: ConvertOptions = useMemo(
() => ({ profile, keepComment, artwork, devices: chosen }),
Expand Down Expand Up @@ -183,6 +185,33 @@ 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() {
setAsking(false);
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;
Expand Down Expand Up @@ -281,6 +310,14 @@ function Window({ choice, onChooseLanguage }: WindowProps) {
</button>

<span className="push" />
<button
className="danger-btn"
disabled={tracks.length === 0 || busy !== null}
onClick={() => setAsking(true)}
type="button"
>
{t.toolbar.clear}
</button>
<button
className="go-btn"
disabled={tracks.length === 0 || busy !== null}
Expand Down Expand Up @@ -332,8 +369,10 @@ function Window({ choice, onChooseLanguage }: WindowProps) {
<div className="rows">
{tracks.map((track, at) => (
<TrackRow
frozen={busy !== null}
index={at}
key={track.path}
onRemove={() => remove(track.path)}
onSelect={() =>
setSelected((was) => (was === track.path ? null : track.path))
}
Expand Down Expand Up @@ -361,6 +400,16 @@ function Window({ choice, onChooseLanguage }: WindowProps) {
<UtilityPanel choice={choice} onChange={onChooseLanguage} />
)}

{asking && (
<Confirm
confirm={t.confirm.clearGo}
note={t.confirm.clearNote(tracks.length)}
onCancel={() => setAsking(false)}
onConfirm={clear}
title={t.confirm.clearTitle}
/>
)}

<footer className="deckbar" data-busy={busy !== null ? "" : undefined}>
{busy === null ? (
/*
Expand Down
64 changes: 64 additions & 0 deletions gui/src/components/Confirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";

import { useStrings } from "../strings";

type Props = {
title: string;
note: string;
/** Wording on the button that goes through with it, not a bare "OK". */
confirm: string;
onConfirm: () => void;
onCancel: () => void;
};

/**
* A question with two answers, asked before something is thrown away.
*
* Escape and the ground behind it both cancel, and the cancel is what holds
* focus when this opens: a dialog that arrives with the destructive button
* under the return key is one that gets dismissed by reflex into the thing it
* was asking about.
*/
export function Confirm({ title, note, confirm, onConfirm, onCancel }: Props) {
const t = useStrings();
const back = useRef<HTMLButtonElement>(null);

useEffect(() => {
back.current?.focus();

function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}

window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);

return (
<div className="veil" onClick={onCancel} role="presentation">
<div
aria-modal="true"
className="ask"
onClick={(event) => event.stopPropagation()}
role="dialog"
>
<p className="ask-title">{title}</p>
<p className="ask-note">{note}</p>

<div className="ask-answers">
<button
className="box-btn"
onClick={onCancel}
ref={back}
type="button"
>
{t.confirm.cancel}
</button>
<button className="danger-btn" onClick={onConfirm} type="button">
{confirm}
</button>
</div>
</div>
</div>
);
}
30 changes: 29 additions & 1 deletion gui/src/components/TrackRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type Props = {
index: number;
selected: boolean;
onSelect: () => void;
onRemove: () => void;
/** Taking a track out mid-run would change what is being converted. */
frozen: boolean;
};

/** A second strip is only worth reading when the verdict actually moves. */
Expand All @@ -16,7 +19,14 @@ const changesAnything = (track: Track) =>
(after, at) => after.ok !== (track.now[at]?.ok ?? after.ok),
);

export function TrackRow({ track, index, selected, onSelect }: Props) {
export function TrackRow({
track,
index,
selected,
onSelect,
onRemove,
frozen,
}: Props) {
const t = useStrings();

const failing = track.now.filter((lamp) => !lamp.ok).length;
Expand Down Expand Up @@ -47,6 +57,24 @@ export function TrackRow({ track, index, selected, onSelect }: Props) {
) : (
<span className="row-judge ng">{t.track.unreadable}</span>
)}
{/*
Its own button rather than a second meaning for the row, and the
click stops here: the row opens on click, and taking a track out
while opening its detail would be two answers to one press.
*/}
<button
aria-label={t.track.remove}
className="row-drop"
disabled={frozen}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
title={t.track.remove}
type="button"
>
×
</button>
</div>

{track.error !== null && <div className="row-error">{track.error}</div>}
Expand Down
10 changes: 10 additions & 0 deletions gui/src/strings/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,18 @@ export const en: Strings = {
keepComment: "Keep comments",
keepArtwork: "Keep artwork",
pick: "Choose tracks",
clear: "Clear",
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",
Expand All @@ -83,6 +92,7 @@ export const en: Strings = {

track: {
unreadable: "Cannot be read",
remove: "Take out of the list",
dither: "dithered",
lampsNow: "NOW",
lampsAfter: "AFTER",
Expand Down
10 changes: 10 additions & 0 deletions gui/src/strings/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,18 @@ export const ja = {
keepComment: "コメントを残す",
keepArtwork: "ジャケットを残す",
pick: "曲を選ぶ",
clear: "すべて外す",
convert: (count: number) => `${count}曲を変換`,
},

confirm: {
cancel: "やめる",
clearTitle: "リストの曲をすべて外しますか",
clearNote: (count: number) =>
`${count}曲がリストから消えます。ファイル自体は消えません。`,
clearGo: "すべて外す",
},

dialog: {
pickTracks: "曲またはフォルダを選択",
pickDrive: "USBを選択",
Expand All @@ -74,6 +83,7 @@ export const ja = {

track: {
unreadable: "読み込めません",
remove: "リストから外す",
dither: "ディザ",
lampsNow: "NOW",
lampsAfter: "変換後",
Expand Down
105 changes: 105 additions & 0 deletions gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -261,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;
Expand Down Expand Up @@ -448,6 +516,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;
}
Expand Down
Loading