feat(ui): rewrite Gallery/Stories/Logs on clean shadcn, delete their CSS (fast mode) (#822)
FAST-mode shadcn migration of three independent areas — Voice Gallery,
Stories editor, and the logs/status footer — onto shadcn primitives
(src/ui barrel over src/components/ui/*) + Tailwind token utilities. Palette
kept; behavior preserved; ~1000 lines of bespoke CSS removed.
Voice Gallery (VoiceGallery.jsx + gallery/{ArchetypeCard,ArchetypesZone,
CommunityZone,ImportsZone}.jsx):
- Zone toggle → <Segmented>; category chips → Button variant="chip"; facet
dropdowns → <Select>; grid/list view toggle → <Segmented>; cards/chips/
buttons/empty/loading → Tailwind token utilities.
- VoiceGallery.css 427 → 70 lines: kept only the now-playing equalizer
@keyframes, the .arch-avatar/.accent-flag/.flag-globe classes rendered by
the out-of-scope utils/archetypeIcons.jsx, and the app-wide .spin helper
(it lived here, NOT in index.css — kept to avoid breaking ~30 consumers).
Stories editor (StoriesEditor.jsx):
- Track grid, chapter bar, cast/projects/split panels, tone/speed drawer,
and native textarea/select/range chrome → Tailwind utilities; reusable
class-string consts hoisted. Drag-reorder, preview chain, generate/stems,
global speed, refs, i18n keys and aria-labels all unchanged.
- StoriesEditor.css 349 → 0 lines (file deleted; import removed). The dead
.stories-track__voice-dot[data-char] palette (no data-char ever set) and
cosmetic webkit scrollbars were dropped.
- Native <select>s get [color-scheme:dark] so the cast/voice pickers render
on dark chrome across WebKit/WebView2/WebKitGTK (matches the old
.facet-select intent; the original cast select was unstyled/light).
Logs/status footer (LogsFooter.jsx):
- Icon buttons, source pills + severity badges, version badge + pulse dot,
discord/contact/donate, log lines and notification severity → Tailwind
utilities. Spinner → motion-safe:animate-spin; reduced-motion via
motion-reduce: variants.
- LogsFooter.css 376 → 78 lines: kept the position:fixed shell + the
.app-container/.rail-right/≤600px ancestor-combinator insets (can't be
element-local), the body ::-webkit-scrollbar, the collapsed/open heights,
and the version-dot-pulse/heart-glow @keyframes.
- The shared --chrome-* token vars and index.css are untouched; Header is
unaffected (no logs-footer__* class is referenced outside this component).
Verified: vite build, oxlint (0), oxfmt --check (clean), vitest (641
passed), bun run test:visual (48 passed), bun install --frozen-lockfile.
Eyeballed all three areas in a stubbed dev build — coherent and on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
8a9c2f1ce4
commit
b940eb460f
@@ -1,8 +1,19 @@
|
||||
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so
|
||||
the main app can freely scroll underneath; we reserve bottom padding
|
||||
via `body { padding-bottom: 28px }` in the global styles (added via
|
||||
the component's mount — see App.jsx). */
|
||||
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so the
|
||||
main app can freely scroll underneath. Everything that can be expressed as
|
||||
element-local styling (buttons, pills, badges, version, discord/donate,
|
||||
log lines, notification items) now lives as Tailwind utilities in
|
||||
LogsFooter.jsx. What stays here CANNOT be element-local Tailwind:
|
||||
|
||||
(a) the fixed footer shell + the ancestor-combinator inset rules
|
||||
(`.app-container .logs-footer`, `.rail-right`, the ≤600px media query) —
|
||||
they depend on the `logs-footer` class being matched by ancestor
|
||||
selectors, which Tailwind utilities can't express;
|
||||
(b) the collapsed/open height;
|
||||
(c) the `::-webkit-scrollbar` pseudo-elements on the scrollable body;
|
||||
(d) the @keyframes referenced via Tailwind arbitrary `[animation:…]` utilities
|
||||
(version-dot-pulse on the update dot, heart-glow on the donate button). */
|
||||
|
||||
/* (a) Fixed footer shell + rail insets ------------------------------------ */
|
||||
.logs-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -11,8 +22,7 @@
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Chrome tokens defined at :root in index.css. The Header topbar
|
||||
uses the same set, so changes here flow to both surfaces. */
|
||||
/* Chrome tokens defined at :root in index.css; shared with the Header topbar. */
|
||||
background: var(--chrome-bg);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
@@ -21,12 +31,9 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The footer sits *beside* the nav rail, never under or over it. The rail is
|
||||
a full-window-height grid column (see .app-container in index.css), so the
|
||||
footer is simply inset by the rail's 48px and joins it on the edge. Units
|
||||
live in the app-container coordinate space, so the 48px stays aligned with
|
||||
the rail's 48px grid column under UI-scale zoom. The splash/wizard footers
|
||||
render outside .app-container and stay full-width. */
|
||||
/* The footer sits *beside* the nav rail (a 48px grid column of .app-container),
|
||||
never under or over it. Splash/wizard footers render outside .app-container
|
||||
and stay full-width. */
|
||||
.app-container .logs-footer {
|
||||
left: 48px;
|
||||
right: 0;
|
||||
@@ -44,333 +51,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* (b) Collapsed/open height ----------------------------------------------- */
|
||||
.logs-footer--collapsed {
|
||||
height: var(--chrome-bar-h);
|
||||
}
|
||||
|
||||
.logs-footer--open {
|
||||
/* height is set inline via React state */
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
/* Drag handle, top bar, left/right clusters, title, badge base, log-line base,
|
||||
and the notification panel layout now live as Tailwind utilities in
|
||||
LogsFooter.jsx. What stays here is the chrome chrome: the fixed footer shell
|
||||
(it's the anchor for the `.app-container .logs-footer` inset combinators + the
|
||||
≤600px media query), every interactive button with its hover/disabled/animation
|
||||
states, the severity color modifiers + their descendant overrides, scrollbar
|
||||
pseudo-elements, and the @keyframes. */
|
||||
/* Thin vertical divider between the UI-scale toggle (leftmost) and the
|
||||
Logs title + source pills. Keeps the two distinct control groups from
|
||||
visually running into each other. */
|
||||
.logs-footer__divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.logs-footer__scale {
|
||||
margin-right: 2px;
|
||||
}
|
||||
/* (c) Scrollbar pseudo-elements for the log body (Tailwind can't target these) */
|
||||
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
|
||||
|
||||
/* Theme color dots */
|
||||
.logs-footer__themes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.logs-footer__theme-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--dot-color, #888);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
.logs-footer__theme-dot:hover {
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--dot-color, #888) 50%, transparent);
|
||||
}
|
||||
.logs-footer__theme-dot.is-active {
|
||||
border-color: var(--dot-color, #888);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--dot-color, #888) 40%, transparent);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.logs-footer__toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--chrome-icon-btn);
|
||||
height: var(--chrome-icon-btn);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.logs-footer__toggle:hover {
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
/* Source pills with badges */
|
||||
.logs-footer__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
height: var(--chrome-pill-h);
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.logs-footer__pill:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.logs-footer__pill--active {
|
||||
background: var(--chrome-accent-bg);
|
||||
border-color: var(--chrome-accent-border);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
.logs-footer__pill--error {
|
||||
color: #fb4934;
|
||||
}
|
||||
.logs-footer__pill--warn {
|
||||
color: #fabd2f;
|
||||
}
|
||||
.logs-footer__badge--error {
|
||||
background: rgba(251, 73, 52, 0.2);
|
||||
color: #fb4934;
|
||||
}
|
||||
.logs-footer__badge--warn {
|
||||
background: rgba(250, 189, 47, 0.2);
|
||||
color: #fabd2f;
|
||||
}
|
||||
|
||||
/* Action buttons on the right */
|
||||
/* Clickable app-version badge → Settings → Updates (sits by the network icon) */
|
||||
.logs-footer__version {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
height: var(--chrome-icon-btn);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--chrome-fg, #ebdbb2);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.logs-footer__version:hover {
|
||||
opacity: 1;
|
||||
color: var(--chrome-accent, #b8bb26);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Update-available: highlight the badge + a pulsing notification dot. */
|
||||
.logs-footer__version--update {
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
color: var(--chrome-accent, #b8bb26);
|
||||
font-weight: 600;
|
||||
}
|
||||
.logs-footer__version-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--chrome-accent, #b8bb26);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 60%, transparent);
|
||||
animation: version-dot-pulse 2s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* (d) Keyframes referenced from LogsFooter.jsx via `[animation:…]` utilities.
|
||||
The update dot keeps pulsing under reduced motion (matches prior behavior);
|
||||
the donate button opts out via `motion-reduce:[animation:none]` in JSX. */
|
||||
@keyframes version-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 55%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--chrome-accent, #b8bb26) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* Discord button */
|
||||
.logs-footer__discord {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--chrome-icon-btn);
|
||||
height: var(--chrome-icon-btn);
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: #7289da;
|
||||
opacity: 0.6;
|
||||
transition: color 0.15s, opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.logs-footer__discord:hover {
|
||||
opacity: 1;
|
||||
color: #5865F2;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Glowing donate heart */
|
||||
.logs-footer__donate {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--chrome-icon-btn);
|
||||
height: var(--chrome-icon-btn);
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: #d3869b;
|
||||
margin-left: 4px;
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
animation: heart-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
.logs-footer__donate:hover {
|
||||
color: var(--chrome-accent);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.logs-footer__donate svg {
|
||||
fill: rgba(211, 134, 155, 0.25);
|
||||
filter: drop-shadow(0 0 4px rgba(211, 134, 155, 0.35));
|
||||
}
|
||||
.logs-footer__donate:hover svg {
|
||||
fill: rgba(211, 134, 155, 0.5);
|
||||
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.6));
|
||||
}
|
||||
@keyframes heart-glow {
|
||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.08); }
|
||||
}
|
||||
.logs-footer__icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
width: var(--chrome-icon-btn);
|
||||
height: var(--chrome-icon-btn);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.logs-footer__icon-btn:hover {
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.logs-footer__icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.logs-footer__icon-btn--report:hover {
|
||||
color: #fabd2f;
|
||||
}
|
||||
.logs-footer__icon-btn .spinner {
|
||||
animation: logs-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes logs-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Body (scrollable log area) */
|
||||
.logs-footer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 4px 8px;
|
||||
user-select: text;
|
||||
}
|
||||
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
|
||||
|
||||
.logs-footer__line--error {
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
}
|
||||
.logs-footer__line--warn {
|
||||
background: rgba(250, 189, 47, 0.04);
|
||||
}
|
||||
/* line-text base lives in JSX utilities; these severity overrides recolor it. */
|
||||
.logs-footer__line--error .logs-footer__line-text { color: #fb4934; }
|
||||
.logs-footer__line--warn .logs-footer__line-text { color: #fabd2f; }
|
||||
|
||||
/* ── Notification panel in footer ────────────────────────────────── */
|
||||
/* notif-body / notif-item base+hover / notif-icon / notif-content layout /
|
||||
notif-msg / notif-action are utilities in JSX. The severity border-left
|
||||
modifiers, the clickable hover, and the `notif-content strong` rule stay. */
|
||||
|
||||
.logs-footer__notif-item--warn { border-left: 2px solid #fabd2f; }
|
||||
.logs-footer__notif-item--error { border-left: 2px solid #fb4934; }
|
||||
.logs-footer__notif-item--info { border-left: 2px solid #83a598; }
|
||||
|
||||
.logs-footer__notif-content strong {
|
||||
font-size: 12px;
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.logs-footer__notif-link {
|
||||
font-size: 11px;
|
||||
color: var(--color-brand);
|
||||
text-decoration: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.logs-footer__notif-link:hover { text-decoration: underline; }
|
||||
|
||||
.logs-footer__notif-hf {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.logs-footer__notif-hf-input {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-fg);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.logs-footer__notif-hf-input:focus {
|
||||
border-color: var(--color-brand);
|
||||
outline: none;
|
||||
}
|
||||
.logs-footer__notif-hf-btn {
|
||||
background: var(--color-brand);
|
||||
color: #1d2021;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.logs-footer__notif-hf-btn:hover { opacity: 0.85; }
|
||||
|
||||
.logs-footer__notif-item--clickable { cursor: pointer; }
|
||||
.logs-footer__notif-item--clickable:hover { background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
/* ── Reduced motion (10x P4, spec §3): the heart stops glowing; the
|
||||
refresh spinner holds a static frame. ───────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logs-footer__donate,
|
||||
.logs-footer__icon-btn .spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,35 +92,76 @@ function SeverityIcon({ level, size = 11 }) {
|
||||
// fields (uiScale, theme, setUiScale, setTheme) are unchanged; only the
|
||||
// rendering moved.
|
||||
|
||||
// Count badge base (layout/typography). The severity color variants
|
||||
// (--error/--warn) remain in LogsFooter.css.
|
||||
const BADGE =
|
||||
// ── Tailwind utility-string presets (replacing the old `logs-footer__*` BEM
|
||||
// classes). Chrome palette is referenced via `--chrome-*` arbitrary values so
|
||||
// the footer keeps its fixed dark-chrome look (same tokens Header uses).
|
||||
// Severity colors stay as literal arbitrary values (#fb4934 / #fabd2f /
|
||||
// #83a598) — NOT text-danger/text-accent — so the footer chrome stays fixed
|
||||
// across themes (the token utilities recolor per [data-theme]).
|
||||
|
||||
const ICON_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] ' +
|
||||
'rounded-[3px] bg-transparent border-0 cursor-pointer transition-all ' +
|
||||
'[color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)] hover:[background:var(--chrome-hover-bg)] ' +
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
|
||||
// Report button: same shell but hovers warn-yellow instead of chrome-fg.
|
||||
const ICON_BTN_REPORT =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] ' +
|
||||
'rounded-[3px] bg-transparent border-0 cursor-pointer transition-all ' +
|
||||
'[color:var(--chrome-fg-muted)] hover:[color:#fabd2f] hover:[background:var(--chrome-hover-bg)]';
|
||||
|
||||
const PILL_BASE =
|
||||
'inline-flex items-center gap-[6px] px-[8px] py-[2px] h-[var(--chrome-pill-h)] rounded-[3px] ' +
|
||||
'bg-transparent border text-[11px] cursor-pointer transition-all [font-family:inherit] ' +
|
||||
'hover:[background:var(--chrome-hover-bg)] hover:[color:var(--chrome-fg)]';
|
||||
|
||||
// Count-badge layout/typography (severity bg+color appended per case).
|
||||
const BADGE_LAYOUT =
|
||||
'inline-flex items-center justify-center min-w-[18px] px-[5px] h-[14px] rounded-[7px] ' +
|
||||
'text-[10px] font-semibold [background:rgba(255,255,255,0.08)] [color:var(--chrome-fg)] ' +
|
||||
'[font-family:var(--chrome-font-mono)]';
|
||||
'text-[10px] font-semibold [font-family:var(--chrome-font-mono)]';
|
||||
const BADGE_NEUTRAL = `${BADGE_LAYOUT} [background:rgba(255,255,255,0.08)] [color:var(--chrome-fg)]`;
|
||||
const BADGE_ERROR = `${BADGE_LAYOUT} [background:rgba(251,73,52,0.2)] [color:#fb4934]`;
|
||||
const BADGE_WARN = `${BADGE_LAYOUT} [background:rgba(250,189,47,0.2)] [color:#fabd2f]`;
|
||||
|
||||
const DISCORD_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#7289da] opacity-60 ' +
|
||||
'transition-[color,opacity,transform] duration-150 hover:opacity-100 hover:[color:#5865F2] hover:scale-110';
|
||||
|
||||
const DONATE_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ml-[4px] ' +
|
||||
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15] ' +
|
||||
'[animation:heart-glow_2.5s_ease-in-out_infinite] motion-reduce:[animation:none]';
|
||||
|
||||
function SourcePill({ source, counts, active, onClick }) {
|
||||
const hasErrors = counts.error > 0;
|
||||
const hasWarns = counts.warn > 0;
|
||||
// Severity color wins over active wins over muted (matches old cascade).
|
||||
const colorClass = hasErrors
|
||||
? '[color:#fb4934]'
|
||||
: hasWarns
|
||||
? '[color:#fabd2f]'
|
||||
: active
|
||||
? '[color:var(--chrome-accent)]'
|
||||
: '[color:var(--chrome-fg-muted)]';
|
||||
const activeClass = active
|
||||
? '[background:var(--chrome-accent-bg)] [border-color:var(--chrome-accent-border)]'
|
||||
: 'border-transparent';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'logs-footer__pill',
|
||||
active ? 'logs-footer__pill--active' : '',
|
||||
hasErrors ? 'logs-footer__pill--error' : hasWarns ? 'logs-footer__pill--warn' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
className={`${PILL_BASE} ${activeClass} ${colorClass}`}
|
||||
onClick={onClick}
|
||||
aria-label={`${source.label} logs${hasErrors ? `, ${counts.error} errors` : hasWarns ? `, ${counts.warn} warnings` : ''}`}
|
||||
>
|
||||
<span className="font-medium">{source.label}</span>
|
||||
{hasErrors && <span className={`${BADGE} logs-footer__badge--error`}>{counts.error}</span>}
|
||||
{!hasErrors && hasWarns && (
|
||||
<span className={`${BADGE} logs-footer__badge--warn`}>{counts.warn}</span>
|
||||
{hasErrors && <span className={BADGE_ERROR}>{counts.error}</span>}
|
||||
{!hasErrors && hasWarns && <span className={BADGE_WARN}>{counts.warn}</span>}
|
||||
{!hasErrors && !hasWarns && counts.total > 0 && (
|
||||
<span className={BADGE_NEUTRAL}>{counts.total}</span>
|
||||
)}
|
||||
{!hasErrors && !hasWarns && counts.total > 0 && <span className={BADGE}>{counts.total}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -398,7 +439,7 @@ export default function LogsFooter() {
|
||||
belong in always-visible chrome. */}
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__toggle"
|
||||
className={ICON_BTN}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? t('logs.expand') : t('logs.collapse')}
|
||||
aria-label={collapsed ? t('logs.expand_aria') : t('logs.collapse_aria')}
|
||||
@@ -436,16 +477,16 @@ export default function LogsFooter() {
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-[2px]">
|
||||
<button
|
||||
className="logs-footer__icon-btn"
|
||||
className={ICON_BTN}
|
||||
onClick={refreshAll}
|
||||
disabled={loading}
|
||||
title={t('logs.refresh')}
|
||||
aria-label={t('logs.refresh_aria')}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
<RefreshCw size={12} className={loading ? 'motion-safe:animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
className="logs-footer__icon-btn"
|
||||
className={ICON_BTN}
|
||||
onClick={onCopy}
|
||||
title={t('logs.copy_visible')}
|
||||
aria-label={t('logs.copy_visible_aria')}
|
||||
@@ -453,7 +494,7 @@ export default function LogsFooter() {
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="logs-footer__icon-btn"
|
||||
className={ICON_BTN}
|
||||
onClick={onClear}
|
||||
title={t('logs.clear')}
|
||||
aria-label={t('logs.clear_aria')}
|
||||
@@ -461,7 +502,7 @@ export default function LogsFooter() {
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="logs-footer__icon-btn logs-footer__icon-btn--report"
|
||||
className={ICON_BTN_REPORT}
|
||||
onClick={onReportIssue}
|
||||
title={t('logs.report_issue')}
|
||||
aria-label={t('logs.report_issue_aria')}
|
||||
@@ -469,7 +510,7 @@ export default function LogsFooter() {
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="logs-footer__icon-btn"
|
||||
className={ICON_BTN}
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('logs.close')}
|
||||
aria-label={t('logs.close_aria')}
|
||||
@@ -480,7 +521,14 @@ export default function LogsFooter() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`logs-footer__version ${updateReady ? 'logs-footer__version--update' : ''}`}
|
||||
className={
|
||||
'shrink-0 px-[6px] h-[var(--chrome-icon-btn)] rounded-[4px] bg-transparent border-0 cursor-pointer ' +
|
||||
'text-[11px] [font-variant-numeric:tabular-nums] tracking-[0.02em] transition-[opacity,color] duration-150 ' +
|
||||
'hover:opacity-100 hover:[color:var(--chrome-accent)] hover:underline ' +
|
||||
(updateReady
|
||||
? 'relative opacity-100 font-semibold [color:var(--chrome-accent)]'
|
||||
: 'opacity-55 [color:var(--chrome-fg)]')
|
||||
}
|
||||
onClick={() => useAppStore.getState().openSettingsTab?.('updates')}
|
||||
title={
|
||||
updateReady
|
||||
@@ -503,12 +551,17 @@ export default function LogsFooter() {
|
||||
}
|
||||
>
|
||||
v{APP_VERSION}
|
||||
{updateReady && <span className="logs-footer__version-dot" aria-hidden="true" />}
|
||||
{updateReady && (
|
||||
<span
|
||||
className="inline-block w-[6px] h-[6px] ml-[5px] rounded-full align-middle [background:var(--chrome-accent)] [animation:version-dot-pulse_2s_ease-in-out_infinite]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__discord"
|
||||
className={DISCORD_BTN}
|
||||
onClick={() => {
|
||||
import('../api/external').then((m) =>
|
||||
m.openExternal('https://discord.gg/bzQavDfVV9'),
|
||||
@@ -523,7 +576,7 @@ export default function LogsFooter() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__discord"
|
||||
className={DISCORD_BTN}
|
||||
onClick={() => useAppStore.getState().setMode?.('contact')}
|
||||
title={t('logs.contact', { defaultValue: 'Contact' })}
|
||||
aria-label={t('logs.contact_aria', { defaultValue: 'Open the contact page' })}
|
||||
@@ -532,7 +585,7 @@ export default function LogsFooter() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__donate"
|
||||
className={DONATE_BTN}
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
@@ -543,7 +596,10 @@ export default function LogsFooter() {
|
||||
</div>
|
||||
|
||||
{!collapsed && active !== 'notifications' && (
|
||||
<div ref={scrollRef} className="logs-footer__body">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="logs-footer__body flex-1 min-h-0 overflow-y-auto overflow-x-auto py-[4px] px-[8px] select-text"
|
||||
>
|
||||
{current.length === 0 && (
|
||||
<div className="p-[12px] [color:var(--chrome-fg-dim)] not-italic text-[11px] text-center">
|
||||
{active === 'frontend' ? t('logs.empty_frontend_short') : t('logs.empty_lines')}
|
||||
@@ -551,15 +607,29 @@ export default function LogsFooter() {
|
||||
)}
|
||||
{current.map((line, i) => {
|
||||
const level = classifyLine(line);
|
||||
const lineBg =
|
||||
level === 'error'
|
||||
? '[background:rgba(251,73,52,0.06)]'
|
||||
: level === 'warn'
|
||||
? '[background:rgba(250,189,47,0.04)]'
|
||||
: '';
|
||||
const lineColor =
|
||||
level === 'error'
|
||||
? '[color:#fb4934]'
|
||||
: level === 'warn'
|
||||
? '[color:#fabd2f]'
|
||||
: '[color:#ebdbb2]';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-start gap-[6px] px-[2px] py-[1px] rounded-[2px] logs-footer__line--${level}`}
|
||||
className={`flex items-start gap-[6px] px-[2px] py-[1px] rounded-[2px] ${lineBg}`}
|
||||
>
|
||||
<span className="pt-[2px] shrink-0">
|
||||
<SeverityIcon level={level} />
|
||||
</span>
|
||||
<pre className="logs-footer__line-text m-0 p-0 [font-family:inherit] text-[11.5px] leading-[1.5] whitespace-pre-wrap [word-break:break-word] [color:#ebdbb2] flex-1">
|
||||
<pre
|
||||
className={`m-0 p-0 [font-family:inherit] text-[11.5px] leading-[1.5] whitespace-pre-wrap [word-break:break-word] flex-1 ${lineColor}`}
|
||||
>
|
||||
{typeof line === 'string' ? line : JSON.stringify(line)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -569,50 +639,65 @@ export default function LogsFooter() {
|
||||
)}
|
||||
|
||||
{!collapsed && active === 'notifications' && (
|
||||
<div className="logs-footer__body flex flex-col gap-[2px] px-[8px] py-[6px]">
|
||||
<div className="logs-footer__body flex flex-col gap-[2px] px-[8px] py-[6px] flex-1 min-h-0 overflow-y-auto select-text">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-[12px] [color:var(--chrome-fg-dim)] not-italic text-[11px] text-center">
|
||||
{t('logs.all_clear')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notif) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`flex gap-[8px] items-start px-[10px] py-[8px] rounded-md [background:rgba(255,255,255,0.02)] [border:1px_solid_transparent] hover:[background:rgba(255,255,255,0.04)] logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
|
||||
onClick={() => {
|
||||
if (!notif.action) return;
|
||||
// Acting on the crash notice acknowledges it — the backend
|
||||
// stores the seen crash-log size so it doesn't re-fire
|
||||
// every session until a NEW crash grows the log.
|
||||
if (notif.id === 'crash-last-session') {
|
||||
import('../api/client')
|
||||
.then(({ apiFetch }) => apiFetch('/system/crash/ack', { method: 'POST' }))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (notif.action.type === 'navigate') {
|
||||
useAppStore.getState().setMode?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
} else if (notif.action.type === 'link') {
|
||||
import('../api/external').then((m) => m.openExternal(notif.action.target));
|
||||
}
|
||||
}}
|
||||
role={notif.action ? 'button' : undefined}
|
||||
tabIndex={notif.action ? 0 : undefined}
|
||||
>
|
||||
<span className="shrink-0 mt-[2px]">
|
||||
<SeverityIcon level={notif.level} />
|
||||
</span>
|
||||
<div className="logs-footer__notif-content flex flex-col gap-[2px] flex-1 min-w-0">
|
||||
<strong>{notif.title}</strong>
|
||||
<span className="text-[11px] text-fg-muted leading-[1.5]">{notif.message}</span>
|
||||
</div>
|
||||
{notif.action && (
|
||||
<span className="shrink-0 text-[11px] font-semibold text-brand whitespace-nowrap">
|
||||
{notif.action.label} →
|
||||
notifications.map((notif) => {
|
||||
// Severity border-left (literal colors, fixed across themes) +
|
||||
// 1px transparent on the other sides for layout parity.
|
||||
const sides =
|
||||
'[border-top:1px_solid_transparent] [border-right:1px_solid_transparent] [border-bottom:1px_solid_transparent]';
|
||||
const notifBorder =
|
||||
notif.level === 'error'
|
||||
? `[border-left:2px_solid_#fb4934] ${sides}`
|
||||
: notif.level === 'warn'
|
||||
? `[border-left:2px_solid_#fabd2f] ${sides}`
|
||||
: `[border-left:2px_solid_#83a598] ${sides}`;
|
||||
const notifHover = notif.action
|
||||
? 'cursor-pointer hover:[background:rgba(255,255,255,0.06)]'
|
||||
: 'hover:[background:rgba(255,255,255,0.04)]';
|
||||
return (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`flex gap-[8px] items-start px-[10px] py-[8px] rounded-md [background:rgba(255,255,255,0.02)] ${notifBorder} ${notifHover}`}
|
||||
onClick={() => {
|
||||
if (!notif.action) return;
|
||||
// Acting on the crash notice acknowledges it — the backend
|
||||
// stores the seen crash-log size so it doesn't re-fire
|
||||
// every session until a NEW crash grows the log.
|
||||
if (notif.id === 'crash-last-session') {
|
||||
import('../api/client')
|
||||
.then(({ apiFetch }) => apiFetch('/system/crash/ack', { method: 'POST' }))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (notif.action.type === 'navigate') {
|
||||
useAppStore.getState().setMode?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
} else if (notif.action.type === 'link') {
|
||||
import('../api/external').then((m) => m.openExternal(notif.action.target));
|
||||
}
|
||||
}}
|
||||
role={notif.action ? 'button' : undefined}
|
||||
tabIndex={notif.action ? 0 : undefined}
|
||||
>
|
||||
<span className="shrink-0 mt-[2px]">
|
||||
<SeverityIcon level={notif.level} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
<div className="flex flex-col gap-[2px] flex-1 min-w-0">
|
||||
<strong className="text-[12px] [color:var(--color-fg)]">{notif.title}</strong>
|
||||
<span className="text-[11px] text-fg-muted leading-[1.5]">{notif.message}</span>
|
||||
</div>
|
||||
{notif.action && (
|
||||
<span className="shrink-0 text-[11px] font-semibold text-brand whitespace-nowrap">
|
||||
{notif.action.label} →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
/* ── Stories / Audiobook Editor ─────────────────────────────────────── */
|
||||
/* Layout/spacing/typography for the editor shell, header, panels, stats and
|
||||
the cast/split rows now live as Tailwind utilities in StoriesEditor.jsx.
|
||||
What remains here is the hard-to-express stuff: the track grid + its
|
||||
hover/active/drag combinators, native control chrome (textarea/select/range/
|
||||
buttons + their focus/hover/disabled states), pseudo-element scrollbars, the
|
||||
per-character color palette attribute selectors, and the chapter bar. */
|
||||
|
||||
.stories-editor__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-fg);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Track list ───────────────────────────────────────────────────── */
|
||||
|
||||
.stories-editor__tracks {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.stories-editor__tracks::-webkit-scrollbar { width: 6px; }
|
||||
.stories-editor__tracks::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Single track row ─────────────────────────────────────────────── */
|
||||
|
||||
.stories-track {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 160px 100px 44px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-bg-elev-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.stories-track:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.stories-track--active {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-glow);
|
||||
}
|
||||
|
||||
.stories-track--narrator {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
.stories-track__grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.stories-track__grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Text area */
|
||||
.stories-track__text {
|
||||
width: 100%;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
padding: 6px 8px;
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.stories-track__text:focus {
|
||||
border-color: var(--color-brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Voice selector */
|
||||
.stories-track__voice-select {
|
||||
flex: 1;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-fg);
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 6px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Character tag */
|
||||
.stories-track__character {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px 8px;
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Track actions */
|
||||
.stories-track__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stories-track__btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stories-track__btn:hover {
|
||||
color: var(--color-fg);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.stories-track__btn--delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Character color palette ──────────────────────────────────────── */
|
||||
|
||||
.stories-track__voice-dot[data-char="narrator"] { background: var(--color-accent); }
|
||||
.stories-track__voice-dot[data-char="char-0"] { background: #d3869b; }
|
||||
.stories-track__voice-dot[data-char="char-1"] { background: #83a598; }
|
||||
.stories-track__voice-dot[data-char="char-2"] { background: #b8bb26; }
|
||||
.stories-track__voice-dot[data-char="char-3"] { background: #fabd2f; }
|
||||
.stories-track__voice-dot[data-char="char-4"] { background: #fe8019; }
|
||||
.stories-track__voice-dot[data-char="char-5"] { background: #8ec07c; }
|
||||
|
||||
|
||||
/* ── Paste & auto-split panel ────────────────────────────────────── */
|
||||
|
||||
.stories-editor__split-text {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
}
|
||||
.stories-editor__split-num {
|
||||
width: 64px;
|
||||
padding: 4px 6px;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
/* ── Cast panel ───────────────────────────────────────────────────── */
|
||||
.stories-cast__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-fg);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stories-cast__add:hover { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.stories-cast__name {
|
||||
flex: 0 0 140px;
|
||||
background: var(--color-bg, rgba(0, 0, 0, 0.2));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-fg);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.stories-cast__select { flex: 1; }
|
||||
.stories-cast__del {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.stories-cast__del:hover:not(:disabled) { color: #fb4934; background: rgba(255, 255, 255, 0.06); }
|
||||
.stories-cast__del:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
/* ── Drag-to-reorder ──────────────────────────────────────────────── */
|
||||
.stories-track { cursor: grab; flex-wrap: wrap; }
|
||||
.stories-track--dragover { box-shadow: inset 0 2px 0 0 var(--color-accent, #b8bb26); }
|
||||
|
||||
/* ── Per-line tone/speed drawer ───────────────────────────────────── */
|
||||
.stories-track__btn--on { color: var(--color-accent); background: rgba(255, 255, 255, 0.06); }
|
||||
.stories-track__tone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
color: var(--color-fg);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stories-track__tone:hover { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.stories-track__speed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-subtle);
|
||||
}
|
||||
.stories-track__speed input[type="range"] { width: 120px; }
|
||||
.stories-track__speed-val { font-family: var(--font-mono); color: var(--color-fg); min-width: 44px; }
|
||||
.stories-track__reset {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-fg-subtle);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stories-track__reset:hover { color: var(--color-fg); }
|
||||
|
||||
/* ── Projects panel ───────────────────────────────────────────────── */
|
||||
.stories-proj__open {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.stories-proj__open:hover { color: var(--color-accent); }
|
||||
.stories-proj--current { background: rgba(184, 187, 38, 0.08); border-radius: var(--radius-sm); }
|
||||
|
||||
/* ── Export format selector ───────────────────────────────────────── */
|
||||
/* Inherits the canonical chrome look from .input-base; only override the
|
||||
toolbar-select sizing (input-base is full-width by default). */
|
||||
.stories-editor__format {
|
||||
width: auto;
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
/* ── Redesign: grouped toolbar, chapter bars, readable column ──────── */
|
||||
|
||||
/* Chapter = a section heading, not a voiced line. */
|
||||
.stories-chapter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 16px 0 4px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
background: var(--color-bg-elev-2);
|
||||
}
|
||||
.stories-chapter--dragover { outline: 1px dashed var(--color-accent); outline-offset: 2px; }
|
||||
.stories-chapter__grip { display: flex; color: var(--color-fg-subtle); cursor: grab; opacity: 0.4; }
|
||||
.stories-chapter:hover .stories-chapter__grip { opacity: 1; }
|
||||
.stories-chapter__icon { flex: none; color: var(--color-accent); }
|
||||
.stories-chapter__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-fg);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.stories-chapter__title::placeholder { color: var(--color-fg-subtle); font-weight: 600; }
|
||||
.stories-chapter__del {
|
||||
flex: none;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
.stories-chapter:hover .stories-chapter__del { opacity: 0.7; }
|
||||
.stories-chapter__del:hover { opacity: 1; color: var(--color-danger, #fb4934); }
|
||||
|
||||
/* Quiet a line's secondary actions until the row is hovered or active. */
|
||||
.stories-track__actions { opacity: 0.5; transition: opacity 0.12s ease; }
|
||||
.stories-track:hover .stories-track__actions,
|
||||
.stories-track--active .stories-track__actions { opacity: 1; }
|
||||
@@ -61,7 +61,21 @@ import { storyToSpans } from '../utils/storyToSpans';
|
||||
import { consumeLongformStream } from '../utils/longformStream';
|
||||
import { reorder } from '../utils/storyReorder';
|
||||
import { effectiveProfile, effectiveSpeed, castMember, nextCastColor } from '../utils/storyCast';
|
||||
import './StoriesEditor.css';
|
||||
|
||||
// ── Shared class strings (replacing the old stories-* BEM chrome) ─────────
|
||||
const ADD_BTN =
|
||||
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:border-accent hover:text-accent';
|
||||
const NAME_INPUT =
|
||||
'bg-bg-elev-2 border border-border rounded-sm text-fg [font-size:var(--text-xs)] px-[8px] py-[4px]';
|
||||
const SELECT_CHROME =
|
||||
'bg-bg-elev-2 border border-border rounded-md text-fg [font-size:var(--text-xs)] px-[6px] py-[4px] [font-family:var(--font-sans)] [color-scheme:dark]';
|
||||
const DEL_BTN =
|
||||
'bg-transparent text-fg-subtle cursor-pointer w-[22px] h-[22px] flex items-center justify-center rounded-sm hover:enabled:text-danger hover:enabled:bg-white/[0.06] disabled:opacity-35 disabled:cursor-not-allowed';
|
||||
const RESET_BTN =
|
||||
'bg-transparent border border-border text-fg-subtle [font-size:var(--text-xs)] px-[8px] py-[2px] rounded-sm cursor-pointer hover:text-fg';
|
||||
const SPEED_RANGE = 'w-[120px]';
|
||||
const TRACK_BTN =
|
||||
'w-[20px] h-[20px] flex items-center justify-center bg-transparent text-fg-subtle cursor-pointer rounded-sm [transition:color_0.15s,background_0.15s] p-0 hover:bg-white/[0.06]';
|
||||
|
||||
// Trigger a browser download for a Blob.
|
||||
function download(blob, filename) {
|
||||
@@ -611,7 +625,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
{/* Header / toolbar */}
|
||||
<div className="flex items-center justify-between gap-[12px]">
|
||||
<div>
|
||||
<h2 className="stories-editor__title">
|
||||
<h2 className="font-serif [font-size:var(--text-xl)] [font-weight:var(--weight-semibold)] text-fg m-0 flex items-center gap-[8px]">
|
||||
<BookOpen size={18} /> {t('stories.title')}
|
||||
</h2>
|
||||
<p className="text-fg-muted [font-size:var(--text-sm)]">{t('stories.subtitle')}</p>
|
||||
@@ -690,7 +704,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
per-line override. */}
|
||||
<div className="inline-flex items-center gap-[4px]">
|
||||
<label
|
||||
className="stories-editor__speed"
|
||||
className="inline-flex items-center gap-[8px] [font-size:var(--text-xs)] text-fg-subtle"
|
||||
title={t('stories.global_speed_hint', {
|
||||
defaultValue: 'Reading speed for all lines without their own speed override',
|
||||
})}
|
||||
@@ -704,14 +718,13 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
value={globalSpeed}
|
||||
onChange={(e) => setGlobalSpeed(parseFloat(e.target.value))}
|
||||
aria-label={t('stories.global_speed', { defaultValue: 'Global reading speed' })}
|
||||
className={SPEED_RANGE}
|
||||
/>
|
||||
<span className="stories-editor__speed-val">{globalSpeed.toFixed(2)}×</span>
|
||||
<span className="[font-family:var(--font-mono)] text-fg min-w-[44px]">
|
||||
{globalSpeed.toFixed(2)}×
|
||||
</span>
|
||||
{globalSpeed !== 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="stories-track__reset"
|
||||
onClick={() => setGlobalSpeed(1)}
|
||||
>
|
||||
<button type="button" className={RESET_BTN} onClick={() => setGlobalSpeed(1)}>
|
||||
{t('stories.reset')}
|
||||
</button>
|
||||
)}
|
||||
@@ -735,7 +748,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<Layers size={13} /> {t('stories.stems')}
|
||||
</Button>
|
||||
<select
|
||||
className="input-base stories-editor__format"
|
||||
className="input-base w-auto [font-size:var(--text-xs)] px-[6px] py-[3px]"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
aria-label={t('stories.format')}
|
||||
@@ -762,13 +775,13 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<span className="[font-size:var(--text-xs)] font-semibold uppercase tracking-[0.06em] text-accent">
|
||||
{t('stories.projects')}
|
||||
</span>
|
||||
<button type="button" className="stories-cast__add" onClick={newStory}>
|
||||
<button type="button" className={ADD_BTN} onClick={newStory}>
|
||||
<Plus size={12} /> {t('stories.newStory')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<input
|
||||
className="stories-cast__name flex-1"
|
||||
className={`${NAME_INPUT} flex-1`}
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder={t('stories.untitled')}
|
||||
@@ -781,18 +794,18 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
{storyProjects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-[8px] ${p.id === currentProjectId ? 'stories-proj--current' : ''}`}
|
||||
className={`flex items-center gap-[8px] ${p.id === currentProjectId ? 'bg-[rgba(184,187,38,0.08)] rounded-sm' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="stories-proj__open"
|
||||
className="flex-1 inline-flex items-center gap-[6px] bg-transparent text-fg [font-size:var(--text-xs)] text-left cursor-pointer px-[2px] py-[4px] hover:text-accent"
|
||||
onClick={() => openProject(p.id)}
|
||||
>
|
||||
<Folder size={12} /> {p.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stories-cast__del"
|
||||
className={DEL_BTN}
|
||||
onClick={() => deleteProject(p.id)}
|
||||
aria-label={t('stories.deleteProject')}
|
||||
>
|
||||
@@ -819,7 +832,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<span className="[font-size:var(--text-xs)] font-semibold uppercase tracking-[0.06em] text-accent">
|
||||
{t('stories.castTitle')}
|
||||
</span>
|
||||
<button type="button" className="stories-cast__add" onClick={addCharacter}>
|
||||
<button type="button" className={ADD_BTN} onClick={addCharacter}>
|
||||
<Plus size={12} /> {t('stories.addCharacter')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -830,13 +843,13 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
style={{ background: c.color }}
|
||||
/>
|
||||
<input
|
||||
className="stories-cast__name"
|
||||
className={`${NAME_INPUT} flex-[0_0_140px]`}
|
||||
value={c.name}
|
||||
onChange={(e) => upsertCastMember({ ...c, name: e.target.value })}
|
||||
aria-label={t('stories.characterName')}
|
||||
/>
|
||||
<select
|
||||
className="stories-cast__select"
|
||||
className={`${SELECT_CHROME} flex-1`}
|
||||
value={c.profileId || ''}
|
||||
onChange={(e) => setCharacterVoice(c.id, e.target.value || null)}
|
||||
aria-label={`${c.name} ${t('stories.voice')}`}
|
||||
@@ -850,7 +863,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="stories-cast__del"
|
||||
className={DEL_BTN}
|
||||
onClick={() => deleteCharacter(c.id)}
|
||||
disabled={c.id === 'narrator'}
|
||||
title={
|
||||
@@ -878,7 +891,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
aria-label={t('stories.pasteSplit')}
|
||||
>
|
||||
<textarea
|
||||
className="stories-editor__split-text"
|
||||
className="w-full min-h-[96px] px-[10px] py-[8px] bg-bg-elev-2 border border-border rounded-sm text-fg [font-family:var(--font-sans)] [font-size:var(--text-sm)] resize-y"
|
||||
placeholder={t('stories.splitPlaceholder')}
|
||||
value={splitText}
|
||||
onChange={(e) => setSplitText(e.target.value)}
|
||||
@@ -895,7 +908,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
step={10}
|
||||
value={splitMax}
|
||||
onChange={(e) => setSplitMax(parseInt(e.target.value, 10) || 180)}
|
||||
className="stories-editor__split-num"
|
||||
className="w-[64px] px-[6px] py-[4px] bg-bg-elev-2 border border-border rounded-sm text-fg [font-family:var(--font-mono)] [font-size:var(--text-xs)]"
|
||||
/>
|
||||
</label>
|
||||
<span className="flex-1 [font-size:var(--text-xs)] text-fg-subtle">
|
||||
@@ -943,7 +956,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stories-editor__tracks" role="list">
|
||||
<div className="flex-1 flex flex-col gap-[6px] overflow-y-auto pr-[4px]" role="list">
|
||||
{tracks.map((track) => {
|
||||
const dragProps = {
|
||||
draggable: true,
|
||||
@@ -974,19 +987,24 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
key={track.id}
|
||||
role="listitem"
|
||||
className={[
|
||||
'stories-chapter',
|
||||
dragOver === track.id ? 'stories-chapter--dragover' : '',
|
||||
'group flex items-center gap-[8px] mt-[16px] mb-[4px] px-[10px] py-[7px] rounded-md [border-left:3px_solid_var(--color-accent)] bg-bg-elev-2',
|
||||
dragOver === track.id
|
||||
? '[outline:1px_dashed_var(--color-accent)] outline-offset-[2px]'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="stories-chapter__grip" aria-hidden="true">
|
||||
<div
|
||||
className="flex text-fg-subtle cursor-grab opacity-40 group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<Bookmark size={15} className="stories-chapter__icon" aria-hidden="true" />
|
||||
<Bookmark size={15} className="flex-none text-accent" aria-hidden="true" />
|
||||
<input
|
||||
className="stories-chapter__title"
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none [font-family:inherit] [font-size:0.95rem] font-bold [letter-spacing:0.01em] text-fg px-0 py-[2px] placeholder:text-fg-subtle placeholder:font-semibold"
|
||||
value={title}
|
||||
onChange={(e) => updateTrack(track.id, 'text', `# ${e.target.value}`)}
|
||||
placeholder={t('stories.addChapter')}
|
||||
@@ -994,7 +1012,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stories-chapter__del"
|
||||
className="flex-none flex p-[4px] rounded-[6px] bg-transparent border-none text-fg-subtle cursor-pointer opacity-0 group-hover:opacity-70 hover:opacity-100 hover:text-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTrack(track.id);
|
||||
@@ -1016,22 +1034,29 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
key={track.id}
|
||||
role="listitem"
|
||||
className={[
|
||||
'stories-track',
|
||||
activeTrack === track.id ? 'stories-track--active' : '',
|
||||
track.character === 'narrator' ? 'stories-track--narrator' : '',
|
||||
dragOver === track.id ? 'stories-track--dragover' : '',
|
||||
'group grid [grid-template-columns:32px_1fr_160px_100px_44px] gap-[8px] items-center px-[10px] py-[8px] bg-bg-elev-1 border border-border rounded-lg [transition:border-color_0.15s,box-shadow_0.15s] cursor-grab flex-wrap hover:border-border-strong hover:[box-shadow:var(--shadow-sm)]',
|
||||
activeTrack === track.id
|
||||
? 'border-brand [box-shadow:0_0_0_1px_var(--color-brand-glow)]'
|
||||
: '',
|
||||
track.character === 'narrator'
|
||||
? '[border-left:3px_solid_var(--color-accent)]'
|
||||
: '',
|
||||
dragOver === track.id ? '[box-shadow:inset_0_2px_0_0_var(--color-accent)]' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setActiveTrack(track.id)}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="stories-track__grip" aria-hidden="true">
|
||||
<div
|
||||
className="flex items-center justify-center text-fg-subtle cursor-grab active:cursor-grabbing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="stories-track__text"
|
||||
className="w-full bg-bg-elev-2 border border-transparent rounded-md text-fg [font-family:var(--font-sans)] [font-size:var(--text-sm)] px-[8px] py-[6px] resize-none min-h-[36px] leading-[1.5] [transition:border-color_0.15s] focus:border-brand focus:outline-none"
|
||||
ref={(el) => {
|
||||
if (el) trackTextRefs.current.set(track.id, el);
|
||||
else trackTextRefs.current.delete(track.id);
|
||||
@@ -1045,11 +1070,11 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span
|
||||
className="stories-track__voice-dot w-[10px] h-[10px] rounded-full shrink-0"
|
||||
className="w-[10px] h-[10px] rounded-full shrink-0"
|
||||
style={{ background: member ? member.color : '#a89984' }}
|
||||
/>
|
||||
<select
|
||||
className="stories-track__voice-select"
|
||||
className={`${SELECT_CHROME} flex-1`}
|
||||
value={track.character}
|
||||
onChange={(e) => updateTrack(track.id, 'character', e.target.value)}
|
||||
aria-label={t('stories.character')}
|
||||
@@ -1063,7 +1088,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="stories-track__character"
|
||||
className="[font-size:var(--text-xs)] text-fg-muted bg-bg-elev-2 border border-border [border-radius:var(--radius-pill)] px-[8px] py-[2px] text-center max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap [color-scheme:dark]"
|
||||
value={track.profileId || ''}
|
||||
onChange={(e) => updateTrack(track.id, 'profileId', e.target.value || null)}
|
||||
aria-label={t('stories.voice')}
|
||||
@@ -1078,7 +1103,11 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="stories-track__actions">
|
||||
<div
|
||||
className={`flex gap-[4px] [transition:opacity_0.12s_ease] ${
|
||||
activeTrack === track.id ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Menu
|
||||
placement="bottom-end"
|
||||
items={[
|
||||
@@ -1098,7 +1127,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
]}
|
||||
>
|
||||
<button
|
||||
className="stories-track__btn"
|
||||
className={`${TRACK_BTN} hover:text-fg`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t('stories.inlineVoiceHint')}
|
||||
aria-label={t('stories.inlineVoice')}
|
||||
@@ -1107,7 +1136,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</button>
|
||||
</Menu>
|
||||
<button
|
||||
className={`stories-track__btn ${expandedLine === track.id ? 'stories-track__btn--on' : ''}`}
|
||||
className={`${TRACK_BTN} hover:text-fg ${expandedLine === track.id ? 'text-accent bg-white/[0.06]' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedLine((id) => (id === track.id ? null : track.id));
|
||||
@@ -1118,7 +1147,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<SlidersHorizontal size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="stories-track__btn"
|
||||
className={`${TRACK_BTN} hover:text-fg`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
insertPauseInto(track.id);
|
||||
@@ -1129,7 +1158,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<PauseIcon size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="stories-track__btn"
|
||||
className={`${TRACK_BTN} hover:text-fg`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
previewTrack(track);
|
||||
@@ -1141,7 +1170,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
{track.generating ? <Mic size={12} className="spinner" /> : <Play size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="stories-track__btn stories-track__btn--delete"
|
||||
className={`${TRACK_BTN} hover:text-danger`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTrack(track.id);
|
||||
@@ -1163,7 +1192,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<button
|
||||
key={tn.tag}
|
||||
type="button"
|
||||
className="stories-track__tone"
|
||||
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:border-accent hover:text-accent"
|
||||
onClick={() => insertTokenInto(track.id, tn.tag)}
|
||||
title={tn.tag}
|
||||
>
|
||||
@@ -1171,7 +1200,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="stories-track__speed">
|
||||
<label className="inline-flex items-center gap-[8px] [font-size:var(--text-xs)] text-fg-subtle">
|
||||
<span>{t('stories.speed')}</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -1181,14 +1210,15 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
value={track.speed || 1}
|
||||
onChange={(e) => updateTrack(track.id, 'speed', parseFloat(e.target.value))}
|
||||
aria-label={t('stories.speed')}
|
||||
className={SPEED_RANGE}
|
||||
/>
|
||||
<span className="stories-track__speed-val">
|
||||
<span className="[font-family:var(--font-mono)] text-fg min-w-[44px]">
|
||||
{(track.speed || 1).toFixed(2)}×
|
||||
</span>
|
||||
{track.speed != null && (
|
||||
<button
|
||||
type="button"
|
||||
className="stories-track__reset"
|
||||
className={RESET_BTN}
|
||||
onClick={() => updateTrack(track.id, 'speed', null)}
|
||||
>
|
||||
{t('stories.reset')}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { facetLabel } from './constants';
|
||||
export default function ArchetypeCard({
|
||||
a,
|
||||
t,
|
||||
viewMode,
|
||||
isFavorite,
|
||||
isPlaying,
|
||||
isLoadingPreview,
|
||||
@@ -32,11 +31,18 @@ export default function ArchetypeCard({
|
||||
? facetLabel(a.facets.accent)
|
||||
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
|
||||
|
||||
const cardBase =
|
||||
'group relative flex flex-col gap-[10px] p-[13px] rounded-[13px] ' +
|
||||
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] border ' +
|
||||
'transition-[transform,border-color,box-shadow] duration-150 ' +
|
||||
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
|
||||
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
|
||||
const cardState = isPlaying
|
||||
? 'border-[color:var(--card-accent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
|
||||
: 'border-white/[0.07] hover:border-white/[0.13]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`}
|
||||
style={{ '--card-accent': color }}
|
||||
>
|
||||
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<ArchetypeAvatar item={a} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -50,7 +56,9 @@ export default function ArchetypeCard({
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`fav-btn ${isFavorite ? 'on' : ''}`}
|
||||
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-colors hover:bg-white/[0.05] ${
|
||||
isFavorite ? 'text-[#fabd2f]' : 'text-[var(--text-secondary)] hover:text-[#fabd2f]'
|
||||
}`}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
>
|
||||
@@ -62,13 +70,13 @@ export default function ArchetypeCard({
|
||||
same height and the action rows align across the grid. */}
|
||||
<div className="flex flex-wrap items-center gap-[5px] min-h-[21px]">
|
||||
{accentLabel && (
|
||||
<span className="facet-chip with-flag">
|
||||
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
|
||||
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
|
||||
{accentLabel}
|
||||
</span>
|
||||
)}
|
||||
{a.facets.whisper && (
|
||||
<span className="facet-chip">
|
||||
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</span>
|
||||
)}
|
||||
@@ -76,7 +84,7 @@ export default function ArchetypeCard({
|
||||
|
||||
<div className="flex items-center gap-[6px] mt-auto">
|
||||
<button
|
||||
className="preview-btn"
|
||||
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] border border-white/[0.09] bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--card-accent)] hover:text-[var(--card-accent)]"
|
||||
onClick={() => onPreview(a)}
|
||||
title={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
>
|
||||
@@ -89,11 +97,14 @@ export default function ArchetypeCard({
|
||||
)}
|
||||
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
|
||||
</button>
|
||||
<button className="use-btn" onClick={() => onUse(a)}>
|
||||
<button
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] border border-[color:color-mix(in_srgb,var(--card-accent)_36%,transparent)] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:border-[color:var(--card-accent)] hover:text-[#1d2021] focus-visible:bg-[var(--card-accent)] focus-visible:border-[color:var(--card-accent)] focus-visible:text-[#1d2021]"
|
||||
onClick={() => onUse(a)}
|
||||
>
|
||||
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</button>
|
||||
<button
|
||||
className="designer-btn"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 border border-white/[0.09] bg-white/[0.03] text-[var(--text-secondary)] rounded-[8px] cursor-pointer opacity-50 transition-[opacity,border-color,color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)] hover:border-[color:var(--card-accent)]"
|
||||
onClick={() => onDesign(a)}
|
||||
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Loader, Star, RotateCcw, Grid, List } from 'lucide-react';
|
||||
import { Button } from '../../ui';
|
||||
import { Button, Select, Segmented } from '../../ui';
|
||||
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
|
||||
import { ArchetypeIcon } from '../../utils/archetypeIcons';
|
||||
import { titleCase, facetLabel } from './constants';
|
||||
@@ -114,36 +114,49 @@ export default function ArchetypesZone({
|
||||
onToggleFavorite: toggleFavorite,
|
||||
});
|
||||
|
||||
const facetGroup =
|
||||
'flex items-center gap-[5px] flex-nowrap min-w-0 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]';
|
||||
const facetToggle =
|
||||
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-[var(--chrome-border)] bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
|
||||
const gridClass =
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]'
|
||||
: 'flex flex-col gap-[6px]';
|
||||
|
||||
return (
|
||||
<div className="gallery-content gallery-scroll">
|
||||
<div className="facet-bar">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-[var(--chrome-border)]">
|
||||
{/* Three filter lanes (categories · facets · toggles), each its own
|
||||
horizontally-scrollable portion; the view toggle is pinned right. */}
|
||||
<div className="facet-group facet-group--cats use-case-chips">
|
||||
<button
|
||||
className={`category-chip ${!filters.use_case ? 'selected' : ''}`}
|
||||
<div className={`${facetGroup} flex-[2.4_1_0]`}>
|
||||
<Button
|
||||
variant="chip"
|
||||
active={!filters.use_case}
|
||||
onClick={() => setFilter('use_case', null)}
|
||||
>
|
||||
{t('gallery.all', { defaultValue: 'All' })}
|
||||
</button>
|
||||
</Button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
<Button
|
||||
key={c.id}
|
||||
className={`category-chip ${filters.use_case === c.id ? 'selected' : ''}`}
|
||||
variant="chip"
|
||||
active={filters.use_case === c.id}
|
||||
leading={<ArchetypeIcon name={c.icon} size={13} />}
|
||||
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
|
||||
title={c.name}
|
||||
>
|
||||
<ArchetypeIcon name={c.icon} size={13} />
|
||||
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="facet-group facet-group--facets facet-selects">
|
||||
<div
|
||||
className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
|
||||
>
|
||||
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
|
||||
<select
|
||||
<Select
|
||||
key={dim}
|
||||
className="facet-select"
|
||||
size="sm"
|
||||
value={filters[dim] ?? ''}
|
||||
onChange={(e) => setFilter(dim, e.target.value || null)}
|
||||
>
|
||||
@@ -155,12 +168,14 @@ export default function ArchetypesZone({
|
||||
{facetLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="facet-group facet-group--toggles">
|
||||
<label className="facet-toggle">
|
||||
<div
|
||||
className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
|
||||
>
|
||||
<label className={facetToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.whisper === true}
|
||||
@@ -168,7 +183,7 @@ export default function ArchetypesZone({
|
||||
/>
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</label>
|
||||
<label className="facet-toggle">
|
||||
<label className={facetToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={favOnly}
|
||||
@@ -176,33 +191,28 @@ export default function ArchetypesZone({
|
||||
/>
|
||||
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
|
||||
</label>
|
||||
<button
|
||||
className="facet-reset"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leading={<RotateCcw size={12} />}
|
||||
onClick={() => {
|
||||
resetFilters();
|
||||
setFavOnly(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} /> {t('gallery.reset', { defaultValue: 'Reset' })}
|
||||
</button>
|
||||
{t('gallery.reset', { defaultValue: 'Reset' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={viewMode === 'grid' ? 'active' : ''}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid"
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'list' ? 'active' : ''}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<Segmented
|
||||
size="xs"
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
items={[
|
||||
{ value: 'grid', label: <Grid size={14} />, title: 'Grid' },
|
||||
{ value: 'list', label: <List size={14} />, title: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFeatured && (
|
||||
@@ -212,7 +222,7 @@ export default function ArchetypesZone({
|
||||
{t('archetypes.featured', { defaultValue: 'Featured' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`archetype-grid ${viewMode}`}>
|
||||
<div className={gridClass}>
|
||||
{applyFav(featured).map((a) => (
|
||||
<ArchetypeCard key={a.id} {...cardProps(a)} />
|
||||
))}
|
||||
@@ -230,18 +240,18 @@ export default function ArchetypesZone({
|
||||
</div>
|
||||
</div>
|
||||
{browseQ.isLoading ? (
|
||||
<div className="loading">
|
||||
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
|
||||
<Loader className="spin" size={18} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`archetype-grid ${viewMode}`}>
|
||||
<div className={gridClass}>
|
||||
{applyFav(browse).map((a) => (
|
||||
<ArchetypeCard key={a.id} {...cardProps(a)} />
|
||||
))}
|
||||
</div>
|
||||
{applyFav(browse).length === 0 && (
|
||||
<div className="empty">
|
||||
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
|
||||
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,8 +29,11 @@ export default function CommunityZone({
|
||||
}
|
||||
};
|
||||
|
||||
const submitBtn =
|
||||
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-white/10 bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
|
||||
|
||||
return (
|
||||
<div className="gallery-content gallery-scroll">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4] flex items-center justify-between gap-[12px] flex-wrap">
|
||||
<span>
|
||||
{t('gallery.community_explainer', {
|
||||
@@ -39,34 +42,33 @@ export default function CommunityZone({
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-[6px] shrink-0">
|
||||
<button className="submit-btn" onClick={() => submit('preset')}>
|
||||
<button className={submitBtn} onClick={() => submit('preset')}>
|
||||
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
|
||||
</button>
|
||||
<button className="submit-btn" onClick={() => submit('voice')}>
|
||||
<button className={submitBtn} onClick={() => submit('voice')}>
|
||||
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemsQ.isLoading ? (
|
||||
<div className="loading">
|
||||
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
|
||||
<Loader className="spin" size={18} />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
|
||||
{t('gallery.community_empty', {
|
||||
defaultValue:
|
||||
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="archetype-grid grid">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]">
|
||||
{items.map((it) => (
|
||||
<ArchetypeCard
|
||||
key={it.id}
|
||||
a={it}
|
||||
t={t}
|
||||
viewMode="grid"
|
||||
isFavorite={favSet.has(it.id)}
|
||||
isPlaying={playingId === it.id}
|
||||
isLoadingPreview={loadingPreviewId === it.id}
|
||||
|
||||
@@ -213,8 +213,13 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
}
|
||||
};
|
||||
|
||||
const voicePlay =
|
||||
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-[var(--chrome-border)] bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
|
||||
const actionBtn =
|
||||
'flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-bg-elev-2 hover:text-[var(--text-primary)]';
|
||||
|
||||
return (
|
||||
<div className="gallery-content">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4]">
|
||||
{t('gallery.import_explainer', {
|
||||
defaultValue:
|
||||
@@ -223,8 +228,9 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-[10px]">
|
||||
<div className="search-row flex gap-[6px]">
|
||||
<div className="flex gap-[6px]">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t('gallery.import_placeholder', {
|
||||
defaultValue: 'Paste a video/audio URL, or type to search…',
|
||||
})}
|
||||
@@ -288,13 +294,19 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
count: results.length,
|
||||
})}
|
||||
</span>
|
||||
<button className="close-btn" onClick={() => setResults([])}>
|
||||
<button
|
||||
className="bg-none border-none text-[var(--text-secondary)] cursor-pointer p-[2px]"
|
||||
onClick={() => setResults([])}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{results.map((r, i) => (
|
||||
<div key={i} className="result-row">
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-[var(--chrome-border)] last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-[2px]">
|
||||
<span className="text-[0.75rem] truncate">{r.title}</span>
|
||||
<span className="text-[0.65rem] text-[var(--text-secondary)]">
|
||||
@@ -320,11 +332,11 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
</div>
|
||||
|
||||
{voicesQ.isLoading ? (
|
||||
<div className="loading">
|
||||
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
|
||||
<Loader className="spin" size={18} />
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="empty">
|
||||
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
|
||||
{t('gallery.no_imports', {
|
||||
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
|
||||
})}
|
||||
@@ -332,8 +344,11 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
) : (
|
||||
<div className="flex flex-col gap-[4px] overflow-y-auto flex-1 pr-[4px]">
|
||||
{voices.map((v) => (
|
||||
<div key={v.id} className="voice-card">
|
||||
<button className="voice-play" onClick={() => onPlayGallery(v)}>
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center gap-[8px] px-[10px] py-[8px] bg-bg-elev-2 rounded-[8px] transition-colors hover:bg-bg-elev-1"
|
||||
>
|
||||
<button className={voicePlay} onClick={() => onPlayGallery(v)}>
|
||||
{loadingPreviewId === v.id ? (
|
||||
<Loader className="spin" size={16} />
|
||||
) : playingId === v.id ? (
|
||||
@@ -350,21 +365,21 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
</div>
|
||||
<div className="flex gap-[3px]">
|
||||
<button
|
||||
className="action-btn"
|
||||
className={actionBtn}
|
||||
onClick={() => handleTrimClick(v)}
|
||||
title={t('gallery.trim', { defaultValue: 'Trim' })}
|
||||
>
|
||||
<Scissors size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
className={actionBtn}
|
||||
onClick={() => handleSaveProfile(v)}
|
||||
title={t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn danger"
|
||||
className="flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-[#3d1f1f] hover:text-[#fb4934]"
|
||||
onClick={() => handleDelete(v)}
|
||||
title={t('gallery.delete', { defaultValue: 'Delete' })}
|
||||
>
|
||||
|
||||
@@ -1,427 +1,70 @@
|
||||
/* .voice-gallery / .gallery-header / .header-top → Tailwind utilities in VoiceGallery.jsx. */
|
||||
/* VoiceGallery.css — trimmed to the irreducible bits after the shadcn/Tailwind
|
||||
migration. Everything layout/chrome moved to Tailwind utilities in the gallery
|
||||
JSX. What remains is only what isn't cleanly expressible as utilities OR is
|
||||
rendered by an out-of-scope file:
|
||||
|
||||
.header-text h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* .gallery-search / .search-row base → Tailwind utilities in ImportsZone.jsx. */
|
||||
|
||||
.search-row .input-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.categories-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--chrome-radius-pill, 999px);
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.category-chip:hover {
|
||||
border-color: var(--chrome-border-strong);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.category-chip.selected {
|
||||
background: var(--chrome-accent, var(--accent));
|
||||
border-color: var(--chrome-accent, var(--accent));
|
||||
color: #1b1b1b;
|
||||
}
|
||||
|
||||
/* .search-results-panel / .panel-header → Tailwind utilities in ImportsZone.jsx. */
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* .results-list → Tailwind utilities in ImportsZone.jsx. */
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-row:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* .result-info / .result-title / .result-meta → Tailwind utilities in ImportsZone.jsx. */
|
||||
|
||||
.gallery-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* .content-header / .content-title base → Tailwind utilities in ArchetypesZone.jsx + ImportsZone.jsx. */
|
||||
|
||||
.content-title .count {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 26px;
|
||||
box-sizing: border-box;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 7px;
|
||||
padding: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
|
||||
.view-toggle button:hover { color: var(--chrome-fg); }
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--chrome-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
• .spin — app-wide spinner class. NOTE: this is NOT in index.css (only
|
||||
`@keyframes spin` + `.spinner` are), and it's used beyond the gallery
|
||||
(e.g. src/pages/AudiobookTab.jsx), so it must live here. Do not delete.
|
||||
• .now-playing equalizer (+ @keyframes eq) and the .arch-avatar / flag
|
||||
classes are all rendered by src/utils/archetypeIcons.jsx (out of scope),
|
||||
which ships no CSS of its own — keep them here. */
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
opacity: 0.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 2px 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* .voice-list base → Tailwind utilities in ImportsZone.jsx (the .grid variant + its
|
||||
descendant overrides below stay in CSS). */
|
||||
|
||||
.voice-list.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.voice-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-bg-elev-2);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.voice-card:hover {
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.voice-play {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: var(--color-bg-elev-1);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-play:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* .voice-info / .voice-name / .voice-meta / .voice-actions base → Tailwind utilities
|
||||
in ImportsZone.jsx (the .voice-list.grid descendant overrides below stay in CSS). */
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--color-bg-elev-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #3d1f1f;
|
||||
color: #fb4934;
|
||||
}
|
||||
|
||||
.voice-list.grid .voice-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.voice-list.grid .voice-play {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.voice-list.grid .voice-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.voice-list.grid .voice-actions {
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Voice Gallery: zone toggle + header ──────────────────────────────── */
|
||||
/* .gallery-sub → Tailwind utilities in VoiceGallery.jsx. */
|
||||
.zone-toggle { display: inline-flex; gap: 2px; background: var(--chrome-hover-bg); border: 1px solid var(--chrome-border); border-radius: 8px; padding: 2px; }
|
||||
.zone-tab { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border: none; background: transparent; color: var(--chrome-fg-muted); border-radius: 6px; font-size: 0.75rem; font-weight: 500; cursor: pointer; transition: background var(--dur-fast), color var(--dur-fast); }
|
||||
.zone-tab:hover { color: var(--chrome-fg); }
|
||||
.zone-tab.active { background: var(--chrome-bg); color: var(--chrome-fg); }
|
||||
.gallery-notice { flex-shrink: 0; padding: 7px 10px; background: var(--color-bg-elev-2); border-left: 3px solid var(--accent); border-radius: 6px; font-size: 0.75rem; color: var(--text-primary); }
|
||||
|
||||
/* ── Facet filter bar ─────────────────────────────────────────────────── */
|
||||
/* One compact line split into three filter lanes — categories · facets ·
|
||||
toggles — each its own horizontally-scrollable portion so a long lane never
|
||||
pushes the others off-screen. The view toggle is pinned at the far right. */
|
||||
.facet-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 0 10px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.facet-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.facet-group::-webkit-scrollbar { height: 5px; }
|
||||
.facet-group::-webkit-scrollbar-thumb { background: var(--chrome-border-strong); border-radius: 3px; }
|
||||
.facet-group::-webkit-scrollbar-track { background: transparent; }
|
||||
/* Width portions: categories get the most room, facets medium, toggles least. */
|
||||
.facet-group--cats { flex: 2.4 1 0; }
|
||||
.facet-group--facets { flex: 1.6 1 0; padding-left: 10px; border-left: 1px solid var(--chrome-border); }
|
||||
.facet-group--toggles { flex: 1 1 0; padding-left: 10px; border-left: 1px solid var(--chrome-border); }
|
||||
.view-toggle { flex: 0 0 auto; }
|
||||
.chip-emoji { font-size: 0.8rem; line-height: 1; }
|
||||
/* Uniform height + box model for every control on the single line. */
|
||||
.facet-select,
|
||||
.facet-toggle,
|
||||
.facet-reset {
|
||||
height: 26px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 9px;
|
||||
border-radius: 7px;
|
||||
font-size: 0.68rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Native <select> falls back to the OS-default (light) control surface when its
|
||||
background resolves to an undefined var, so pin real dark-chrome tokens. */
|
||||
.facet-select {
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
color-scheme: dark;
|
||||
}
|
||||
/* Belt-and-suspenders: explicit dark option list for WebViews that ignore
|
||||
color-scheme on the popup (cross-platform parity: WebKit / WebView2 / WebKitGTK). */
|
||||
.facet-select option { background: var(--chrome-bg); color: var(--chrome-fg); }
|
||||
/* Whisper / Favorites read as pill toggles matching the dropdowns. */
|
||||
.facet-toggle {
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.facet-toggle:hover { color: var(--chrome-fg); border-color: var(--chrome-border-strong); }
|
||||
.facet-reset {
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.facet-reset:hover { border-color: var(--chrome-border-strong); color: var(--chrome-fg); }
|
||||
/* A small gap separates the view toggle from the filter controls on the line. */
|
||||
.facet-selects .view-toggle { margin-left: 2px; }
|
||||
|
||||
/* ── Archetype sections + grid ────────────────────────────────────────── */
|
||||
.gallery-content.gallery-scroll { overflow-y: auto; }
|
||||
/* .archetype-section / .count-badge → Tailwind utilities in ArchetypesZone.jsx + ImportsZone.jsx. */
|
||||
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 10px; }
|
||||
.archetype-grid.list { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
/* Card surface — a real, elevated tile so cards read as cards on the dark bg. */
|
||||
.archetype-card {
|
||||
/* Color-coded avatar tile + flag badge (archetypeIcons.jsx → ArchetypeAvatar). */
|
||||
.arch-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.012));
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 13px;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.archetype-card:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.13); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); }
|
||||
.archetype-card.playing { border-color: var(--card-accent, var(--accent)); box-shadow: 0 0 0 1px var(--card-accent, var(--accent)), 0 6px 22px rgba(0, 0, 0, 0.4); }
|
||||
|
||||
/* Header: avatar tile + title + favorite */
|
||||
/* .arch-head → Tailwind utilities in ArchetypeCard.jsx. */
|
||||
.arch-avatar { position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 11px; }
|
||||
.arch-avatar-flag { position: absolute; right: -4px; bottom: -4px; display: flex; line-height: 0; border-radius: 3px; overflow: hidden; box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021); }
|
||||
.accent-flag { display: block; border-radius: 2px; }
|
||||
.flag-globe { color: var(--text-secondary); }
|
||||
/* .arch-title / .archetype-name / .archetype-sub → Tailwind utilities in ArchetypeCard.jsx. */
|
||||
|
||||
.fav-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
|
||||
.fav-btn:hover { color: #fabd2f; background: rgba(255, 255, 255, 0.05); }
|
||||
.fav-btn.on { color: #fabd2f; }
|
||||
|
||||
/* Facet chips (accent chip carries its flag). Always rendered (even when a
|
||||
voice has no accent/whisper chip) with a reserved min-height so every card
|
||||
shares the same vertical rhythm and the action rows line up across the grid
|
||||
instead of floating at ragged heights. */
|
||||
/* .archetype-chips → Tailwind utilities in ArchetypeCard.jsx. */
|
||||
.facet-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--text-secondary); font-size: 0.64rem; line-height: 1.6; }
|
||||
.facet-chip.with-flag { padding-left: 5px; }
|
||||
|
||||
/* Footer actions */
|
||||
/* .arch-foot → Tailwind utilities in ArchetypeCard.jsx. */
|
||||
.preview-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||
.preview-btn:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); }
|
||||
/* Primary action — tonal by default (a low-alpha wash of the card accent with
|
||||
accent-colored text), going solid only on hover/focus. This keeps the grid
|
||||
calm at rest — the eye lands on the voice names, not a field of saturated
|
||||
color blocks — while the CTA still "lights up" on the card you're pointing
|
||||
at. The per-category hue is preserved as identity, just dialed down. */
|
||||
.use-btn {
|
||||
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 6px 10px; border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--card-accent, var(--accent)) 36%, transparent);
|
||||
background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);
|
||||
color: var(--card-accent, var(--accent));
|
||||
font-size: 0.72rem; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
.arch-avatar-flag {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
display: flex;
|
||||
line-height: 0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021);
|
||||
}
|
||||
.use-btn:hover, .use-btn:focus-visible {
|
||||
background: var(--card-accent, var(--accent));
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
color: #1d2021;
|
||||
.accent-flag {
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.flag-globe {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Designer/open-in-designer is a tertiary action — keep it quiet at rest and
|
||||
reveal it on card hover/focus so it isn't competing on every tile. */
|
||||
.designer-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border-radius: 8px; cursor: pointer; opacity: 0.5; transition: opacity 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
||||
.archetype-card:hover .designer-btn, .designer-btn:focus-visible { opacity: 1; }
|
||||
.designer-btn:hover { color: var(--card-accent, var(--accent)); border-color: var(--card-accent, var(--accent)); }
|
||||
|
||||
/* Animated "now playing" equalizer */
|
||||
.now-playing { display: inline-flex; align-items: flex-end; gap: 2px; width: 15px; height: 14px; }
|
||||
.now-playing i { width: 2.5px; background: currentcolor; border-radius: 1px; transform-origin: bottom; animation: eq 0.9s ease-in-out infinite; }
|
||||
/* Animated "now playing" equalizer (archetypeIcons.jsx → NowPlaying). */
|
||||
.now-playing {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
width: 15px;
|
||||
height: 14px;
|
||||
}
|
||||
.now-playing i {
|
||||
width: 2.5px;
|
||||
background: currentcolor;
|
||||
border-radius: 1px;
|
||||
transform-origin: bottom;
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.now-playing i:nth-child(1) { height: 45%; animation-delay: 0s; }
|
||||
.now-playing i:nth-child(2) { height: 80%; animation-delay: 0.15s; }
|
||||
.now-playing i:nth-child(3) { height: 60%; animation-delay: 0.3s; }
|
||||
.now-playing i:nth-child(4) { height: 95%; animation-delay: 0.45s; }
|
||||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .now-playing i { animation: none; } .archetype-card { transition: none; } .archetype-card:hover { transform: none; } }
|
||||
|
||||
/* Category chips: lucide icon + label */
|
||||
.use-case-chips .category-chip svg { flex-shrink: 0; }
|
||||
|
||||
/* .load-more / .import-explainer / .community-explainer / .submit-actions
|
||||
→ Tailwind utilities in ArchetypesZone.jsx, CommunityZone.jsx + ImportsZone.jsx. */
|
||||
.submit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||
.submit-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
@keyframes eq {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.now-playing i { animation: none; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, Store, Upload } from 'lucide-react';
|
||||
import { Segmented } from '../ui';
|
||||
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
|
||||
import { previewVoiceUrl } from '../api/gallery';
|
||||
import { useAppStore } from '../store';
|
||||
@@ -146,42 +147,56 @@ export default function VoiceGallery() {
|
||||
}
|
||||
};
|
||||
|
||||
const zoneItems = [
|
||||
{
|
||||
value: 'archetypes',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'community',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'imports',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[12px] p-[12px] h-full overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="header-text">
|
||||
<h2>{t('gallery.title', { defaultValue: 'OmniVoice Gallery' })}</h2>
|
||||
<div>
|
||||
<h2 className="text-[1.1rem] font-semibold m-0 text-[var(--text-primary)]">
|
||||
{t('gallery.title', { defaultValue: 'OmniVoice Gallery' })}
|
||||
</h2>
|
||||
<p className="mt-[2px] mr-0 mb-0 ml-0 text-[0.72rem] text-[var(--text-secondary)]">
|
||||
{t('gallery.subtitle', {
|
||||
defaultValue: 'Hundreds of ready-made designed voices — pick one and go.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="zone-toggle">
|
||||
<button
|
||||
className={`zone-tab ${zone === 'archetypes' ? 'active' : ''}`}
|
||||
onClick={() => setZone('archetypes')}
|
||||
>
|
||||
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
|
||||
</button>
|
||||
<button
|
||||
className={`zone-tab ${zone === 'community' ? 'active' : ''}`}
|
||||
onClick={() => setZone('community')}
|
||||
>
|
||||
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
|
||||
</button>
|
||||
<button
|
||||
className={`zone-tab ${zone === 'imports' ? 'active' : ''}`}
|
||||
onClick={() => setZone('imports')}
|
||||
>
|
||||
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
|
||||
</button>
|
||||
</div>
|
||||
<Segmented items={zoneItems} value={zone} onChange={setZone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="gallery-notice">{notice}</div>}
|
||||
{notice && (
|
||||
<div className="shrink-0 px-[10px] py-[7px] bg-bg-elev-2 border-l-[3px] border-l-[color:var(--accent)] rounded-[6px] text-[0.75rem] text-[var(--text-primary)]">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zone === 'archetypes' ? (
|
||||
<ArchetypesZone
|
||||
|
||||
Reference in New Issue
Block a user