Files
RUWIO/src/components/DocumentSettings.vue
T
thandClaude Opus 4.7 9fe86b62b9 0.14.0: multi-folder media drawer
- Clipart store holds an array of root folders instead of a single
  handle, plus an activeHandleIndex for the in-folder view. Getters:
  activeHandle, inOverview, hasAnyFolder, canGoBack, folderName.
- Actions: addFolder (appends a root), enterFolder, exitFolder,
  removeFolder. Single-folder installs auto-drill into the sole root
  and behave exactly like the pre-refactor drawer; adding a 2nd+
  folder pops back to the root overview.
- IDB persistence migrated from the 'clipartDir' single-handle key to
  a 'clipartDirs' array. Legacy records are migrated on first load.
  saveClipartFolders unwraps Pinia proxies (toRaw per element) so
  IDB's structured clone accepts the payload.
- ClipartItem union gained a 'folder' kind so root tiles render as
  first-class media items. listClipart emits nested directories too,
  but the drawer keeps drill-down one level deep for now.
- ClipartDrawer header: three equally-sized .header-btn chips —
  back (only when canGoBack), refresh (when viewing a folder), and a
  new `+` (when any folder is attached). No more big 'Choose folder'
  button — empty state is a centred 96px dashed + with a wrapped hint.
- Folder tiles carry a hover `×` to remove a root from the library.
- DocumentSettings media tab lists all configured folders with a per-
  folder remove action; primary button is now 'Add folder'.
- Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps
  the store definition between reloads instead of leaving the first-
  load state shape behind.
- Tests: store actions (addFolder / enterFolder / exitFolder / remove
  / IDB round-trip), drawer UI (centred + empty state, overview with
  folder tiles + click-to-enter, back visibility, single-root hides
  back, header + button triggers addFolder). Tests isolated with
  try/finally so a failing store assertion can't leak error state
  into subsequent drawer tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:33:20 +02:00

730 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhFolder,
PhMoon,
PhSlidersHorizontal,
PhSun,
} from '@phosphor-icons/vue'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
// case where initial App.vue init raced ahead of IDB or the user opens
// settings before the drawer ever mounted.
watch(
() => props.modelValue,
(open) => {
if (open) void clipart.initialize()
},
)
type TabId = 'clipart' | 'interface'
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
{ id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
{ id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
]
const activeTab = ref<TabId>('clipart')
const selectedLocale = computed({
get: () => locale.value,
set: (value: Locale) => setLocale(value),
})
function close() {
emit('update:modelValue', false)
}
function applyTheme(t: Theme) {
setTheme(t)
}
</script>
<template>
<Modal
:model-value="modelValue"
:title="t('settings.title')"
size="lg"
:dialog-style="{
width: 'min(920px, calc(100% - 32px))',
height: 'min(620px, calc(100vh - 48px))',
}"
:body-style="{
display: 'flex',
overflow: 'hidden',
minHeight: 0,
}"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="body">
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="14" />
{{ t(tab.labelKey) }}
</button>
</nav>
<div class="content">
<section v-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
<ul v-if="clipart.hasAnyFolder" class="clipart-folder-list">
<li
v-for="(h, i) in clipart.handles"
:key="h.name + i"
class="clipart-value"
>
<PhFolder :size="13" />
<span class="clipart-folder-name">{{ h.name }}</span>
<button
class="ghost-btn small"
v-tooltip="t('clipart.removeFolder')"
@click="clipart.removeFolder(i)"
>
{{ t('clipart.clearFolder') }}
</button>
</li>
</ul>
<div v-else class="clipart-value">
<PhFolder :size="13" />
<span class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
</div>
</div>
<div class="clipart-actions">
<button
class="primary-btn"
:disabled="!clipart.supported"
@click="clipart.addFolder()"
>
{{ t('clipart.addFolder') }}
</button>
</div>
<p v-if="!clipart.supported" class="error-line">
{{ t('clipart.unsupported') }}
</p>
<p v-if="clipart.error" class="error-line">{{ clipart.error }}</p>
<p class="hint">{{ t('clipart.settingsHint') }}</p>
</section>
<section v-else-if="activeTab === 'interface'">
<h3>{{ t('settings.tab.interface') }}</h3>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.languageTitle') }}</div>
<div class="setting-desc">{{ t('settings.languageDesc') }}</div>
<label class="setting-select">
<span class="setting-label">{{ t('settings.languageLabel') }}</span>
<select v-model="selectedLocale">
<option value="en">{{ t('settings.languageEnglish') }}</option>
<option value="de">{{ t('settings.languageGerman') }}</option>
</select>
</label>
</div>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
</div>
</label>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div>
<div class="theme-grid">
<button
class="theme-card"
:class="{ active: theme === 'dark' }"
@click="applyTheme('dark')"
>
<div class="theme-preview theme-preview-dark">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhMoon :size="14" />
<span>{{ t('settings.themeDark') }}</span>
</div>
</button>
<button
class="theme-card"
:class="{ active: theme === 'light' }"
@click="applyTheme('light')"
>
<div class="theme-preview theme-preview-light">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhSun :size="14" />
<span>{{ t('settings.themeLight') }}</span>
</div>
</button>
</div>
</div>
</section>
</div>
</div>
<template #footer>
<button class="primary" @click="close">{{ t('settings.done') }}</button>
</template>
</Modal>
</template>
<style scoped>
.body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.tabs {
width: 150px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--panel-alt);
min-height: 0;
}
.tab {
justify-content: flex-start;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
font-size: 12px;
border-radius: var(--radius-sm);
}
.tab:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.tab.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.content {
flex: 1;
padding: 16px 20px;
overflow: auto;
min-width: 0;
min-height: 0;
}
section {
display: flex;
flex-direction: column;
gap: 12px;
}
section h3 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
color: var(--text-dim-strong);
letter-spacing: 0.08em;
font-weight: 600;
}
/* A second h3 inside the same section (e.g. "Timing" under the
Document tab) needs breathing room so it reads as its own subgroup
rather than colliding with the preceding hint paragraph. */
section h3.sub-heading {
margin-top: 12px;
}
.row {
display: flex;
gap: 10px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1 1 0;
min-width: 0;
}
label.stacked {
flex: 0 0 auto;
}
input[type='number'] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset {
padding: 4px 10px;
font-size: 11px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
}
.preset:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.preset.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.color-swatch {
width: 36px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.hex {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
}
.hint {
font-size: 13px;
line-height: 1.45;
color: var(--text-dim-strong);
margin: 0;
}
.setting-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
}
.setting-heading {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.setting-desc {
font-size: 12px;
line-height: 1.45;
color: var(--text-dim);
}
.setting-select {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.setting-label {
color: var(--text-dim);
}
.setting-select select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
text-transform: none;
letter-spacing: normal;
}
/* Switch + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-size: 13px;
text-transform: none;
letter-spacing: normal;
color: var(--text);
flex: 0 0 auto;
}
.row-toggle-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
text-transform: none;
}
.row-toggle-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
letter-spacing: normal;
text-transform: none;
}
.row-toggle-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
letter-spacing: normal;
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.clipart-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.clipart-value {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
}
.clipart-folder-name {
flex: 1 1 auto;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clipart-folder-empty {
color: var(--text-dim);
font-style: italic;
}
.clipart-folder-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ghost-btn.small {
padding: 2px 8px;
font-size: 11px;
}
.clipart-actions {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.primary-btn {
padding: 6px 12px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text, #fff);
border: 1px solid transparent;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.primary-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.primary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ghost-btn {
padding: 6px 12px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
border: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
}
.ghost-btn:hover {
background: var(--panel-alt);
}
.error-line {
margin: 0 0 8px;
color: var(--danger);
font-size: 11px;
}
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.theme-card {
flex-direction: column;
padding: 12px;
gap: 10px;
background: var(--panel-alt);
border: 2px solid var(--border);
border-radius: var(--radius);
align-items: stretch;
}
.theme-card:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.theme-card.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.theme-preview {
height: 74px;
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
gap: 6px;
padding: 8px;
}
.theme-preview-dark {
background: #17171b;
border: 1px solid #2e2e37;
}
.theme-preview-dark .tp-panel {
background: #272730;
flex: 1;
border-radius: 3px;
}
.theme-preview-dark .tp-accent {
background: #6ea8ff;
width: 22px;
border-radius: 3px;
}
.theme-preview-light {
background: #f3f3f6;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-panel {
background: #ffffff;
flex: 1;
border-radius: 3px;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-accent {
background: #2b6af5;
width: 22px;
border-radius: 3px;
}
.theme-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
font-weight: 500;
justify-content: center;
}
.theme-card.active .theme-label {
color: var(--accent);
}
.primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
font-weight: 500;
}
.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
</style>