mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(manacore/web): enhance Times & Zitare pages, add DetailViews, clean up homepage
- Times ListView: inline timer with start/stop, name input, live elapsed display - Times DetailView: editable entry with duration (h/m/s), project, billable, delete - Zitare ListView: tap-to-cycle quotes, inline fav button, tag drag-and-drop support - Zitare DetailView: full quote view with category, author bio, share, favorite - Homepage: remove tag bar (available via PillNav), carousel uses full width - Register both DetailViews in app-registry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5906e4c29
commit
1245fdc077
8 changed files with 981 additions and 127 deletions
|
|
@ -161,9 +161,9 @@
|
|||
/* Carousel track */
|
||||
.fokus-track {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding: 1rem calc(50% - var(--sheet-width) / 2);
|
||||
padding: 0.5rem 0;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,12 +63,20 @@ export const APP_REGISTRY: AppEntry[] = [
|
|||
name: 'Times',
|
||||
color: '#F59E0B',
|
||||
load: () => import('$lib/modules/times/ListView.svelte'),
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/times/ListView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/times/views/DetailView.svelte') },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zitare',
|
||||
name: 'Zitare',
|
||||
color: '#EC4899',
|
||||
load: () => import('$lib/modules/zitare/ListView.svelte'),
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/zitare/ListView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/zitare/views/DetailView.svelte') },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
|
|
|
|||
|
|
@ -1,17 +1,36 @@
|
|||
<!--
|
||||
Times — Workbench ListView
|
||||
Today's time entries with running timer and daily total.
|
||||
Inline timer with start/stop + today's time entries.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
|
||||
import { formatDuration } from '$lib/modules/times/queries';
|
||||
import { Play, Stop } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalTimeEntry, LocalProject } from './types';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
let entries = $state<LocalTimeEntry[]>([]);
|
||||
let projects = $state<LocalProject[]>([]);
|
||||
let description = $state('');
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Initialize timer store to pick up running timers
|
||||
$effect(() => {
|
||||
timerStore.initialize();
|
||||
});
|
||||
|
||||
// Sync description with running entry
|
||||
$effect(() => {
|
||||
if (timerStore.runningEntry) {
|
||||
description = timerStore.runningEntry.description || '';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
return db
|
||||
|
|
@ -38,12 +57,10 @@
|
|||
|
||||
const todayEntries = $derived(
|
||||
entries
|
||||
.filter((e) => e.date === todayStr)
|
||||
.filter((e) => e.date === todayStr && !e.isRunning)
|
||||
.sort((a, b) => (b.startTime ?? '').localeCompare(a.startTime ?? ''))
|
||||
);
|
||||
|
||||
const running = $derived(entries.find((e) => e.isRunning));
|
||||
|
||||
const totalToday = $derived(todayEntries.reduce((sum, e) => sum + e.duration, 0));
|
||||
|
||||
function projectName(projectId?: string | null): string {
|
||||
|
|
@ -51,45 +68,93 @@
|
|||
return projects.find((p) => p.id === projectId)?.name ?? 'Projekt';
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
function fmtCompact(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
async function handleStartStop() {
|
||||
if (timerStore.isRunning) {
|
||||
await timerStore.stop();
|
||||
description = '';
|
||||
} else {
|
||||
await timerStore.start({ description });
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleDescriptionInput(value: string) {
|
||||
description = value;
|
||||
if (!timerStore.isRunning) return;
|
||||
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timerStore.updateRunning({ description: value });
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 p-4">
|
||||
<!-- Running timer -->
|
||||
{#if running}
|
||||
<div class="rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-green-400"></div>
|
||||
<p class="text-sm font-medium text-white/80">{running.description || 'Timer läuft'}</p>
|
||||
<!-- Inline Timer -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={handleStartStop}
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors {timerStore.isRunning
|
||||
? 'bg-red-500/80 text-white hover:bg-red-500'
|
||||
: 'bg-white/10 text-white/50 hover:bg-green-500/80 hover:text-white'}"
|
||||
>
|
||||
{#if timerStore.isRunning}
|
||||
<Stop size={14} weight="fill" />
|
||||
{:else}
|
||||
<Play size={14} weight="fill" />
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
oninput={(e) => handleDescriptionInput((e.target as HTMLInputElement).value)}
|
||||
placeholder="Was trackst du?"
|
||||
class="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||
/>
|
||||
{#if timerStore.isRunning}
|
||||
<div class="flex h-7 items-center gap-1.5 rounded-full bg-green-500/10 px-2.5">
|
||||
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-green-400"></div>
|
||||
<span class="font-mono text-xs text-green-400">
|
||||
{formatDuration(timerStore.elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-white/40">{projectName(running.projectId)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Today stats -->
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Heute: {todayEntries.length} Einträge</span>
|
||||
<span class="font-medium text-white/60">{formatDuration(totalToday)}</span>
|
||||
<span>Heute: {todayEntries.length} Eintr{todayEntries.length === 1 ? 'ag' : 'age'}</span>
|
||||
<span class="font-medium text-white/60">{fmtCompact(totalToday)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Entry list -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#each todayEntries as entry (entry.id)}
|
||||
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
|
||||
<button
|
||||
onclick={() => navigate('detail', { entryId: entry.id })}
|
||||
class="mb-1 w-full rounded-md px-3 py-2 text-left transition-colors hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="truncate text-sm text-white/80">{entry.description || 'Ohne Beschreibung'}</p>
|
||||
<span class="shrink-0 text-xs text-white/50">{formatDuration(entry.duration)}</span>
|
||||
<p class="truncate text-sm text-white/80">
|
||||
{entry.description || 'Ohne Beschreibung'}
|
||||
</p>
|
||||
<span class="shrink-0 text-xs text-white/50">{fmtCompact(entry.duration)}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/30">{projectName(entry.projectId)}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if todayEntries.length === 0 && !running}
|
||||
<p class="py-8 text-center text-sm text-white/30">Noch keine Zeiteinträge heute</p>
|
||||
{#if todayEntries.length === 0 && !timerStore.isRunning}
|
||||
<p class="py-8 text-center text-sm text-white/30">Noch keine Zeiteinträge heute</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,524 @@
|
|||
<!--
|
||||
Times — DetailView (inline editable time entry overlay)
|
||||
All fields are always editable. Changes auto-save on blur.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { Trash } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalTimeEntry, LocalProject, LocalClient } from '../types';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let entryId = $derived(params.entryId as string);
|
||||
|
||||
let entry = $state<LocalTimeEntry | null>(null);
|
||||
let projects = $state<LocalProject[]>([]);
|
||||
let clients = $state<LocalClient[]>([]);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
// Edit fields
|
||||
let editDescription = $state('');
|
||||
let editDate = $state('');
|
||||
let editDurationH = $state(0);
|
||||
let editDurationM = $state(0);
|
||||
let editDurationS = $state(0);
|
||||
let editProjectId = $state<string | null>(null);
|
||||
let editBillable = $state(false);
|
||||
|
||||
let focused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
entryId;
|
||||
confirmDelete = false;
|
||||
focused = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() => db.table<LocalTimeEntry>('timeEntries').get(entryId)).subscribe(
|
||||
(val) => {
|
||||
entry = val ?? null;
|
||||
if (val && !focused) {
|
||||
editDescription = val.description ?? '';
|
||||
editDate = val.date ?? '';
|
||||
const dur = val.duration ?? 0;
|
||||
editDurationH = Math.floor(dur / 3600);
|
||||
editDurationM = Math.floor((dur % 3600) / 60);
|
||||
editDurationS = dur % 60;
|
||||
editProjectId = val.projectId ?? null;
|
||||
editBillable = val.isBillable;
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () =>
|
||||
db
|
||||
.table<LocalProject>('timesProjects')
|
||||
.toArray()
|
||||
.then((all) => all.filter((p) => !p.deletedAt && !p.isArchived))
|
||||
).subscribe((val) => {
|
||||
projects = val ?? [];
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () =>
|
||||
db
|
||||
.table<LocalClient>('timeClients')
|
||||
.toArray()
|
||||
.then((all) => all.filter((c) => !c.deletedAt))
|
||||
).subscribe((val) => {
|
||||
clients = val ?? [];
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
let selectedProject = $derived(
|
||||
editProjectId ? projects.find((p) => p.id === editProjectId) : null
|
||||
);
|
||||
|
||||
let selectedClient = $derived(
|
||||
selectedProject?.clientId ? clients.find((c) => c.id === selectedProject!.clientId) : null
|
||||
);
|
||||
|
||||
function durationSeconds(): number {
|
||||
return editDurationH * 3600 + editDurationM * 60 + editDurationS;
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function saveField() {
|
||||
focused = false;
|
||||
await db.table('timeEntries').update(entryId, {
|
||||
description: editDescription.trim(),
|
||||
date: editDate,
|
||||
duration: durationSeconds(),
|
||||
projectId: editProjectId,
|
||||
clientId: selectedProject?.clientId ?? null,
|
||||
isBillable: editBillable,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleProjectChange(projectId: string | null) {
|
||||
editProjectId = projectId;
|
||||
const project = projectId ? projects.find((p) => p.id === projectId) : null;
|
||||
if (project) editBillable = project.isBillable;
|
||||
await db.table('timeEntries').update(entryId, {
|
||||
projectId: projectId,
|
||||
clientId: project?.clientId ?? null,
|
||||
isBillable: project?.isBillable ?? editBillable,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBillableChange() {
|
||||
editBillable = !editBillable;
|
||||
await db.table('timeEntries').update(entryId, { isBillable: editBillable });
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
await db.table('timeEntries').update(entryId, { deletedAt: new Date().toISOString() });
|
||||
goBack();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !entry}
|
||||
<p class="empty">Eintrag nicht gefunden</p>
|
||||
{:else}
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<input
|
||||
class="title-input"
|
||||
bind:value={editDescription}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Beschreibung..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time info -->
|
||||
{#if entry.startTime || entry.endTime}
|
||||
<div class="time-range">
|
||||
{#if entry.startTime}{fmtTime(entry.startTime)}{/if}
|
||||
{#if entry.startTime && entry.endTime}
|
||||
–
|
||||
{/if}
|
||||
{#if entry.endTime}{fmtTime(entry.endTime)}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Properties -->
|
||||
<div class="properties">
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Datum</span>
|
||||
<input
|
||||
type="date"
|
||||
class="prop-input"
|
||||
bind:value={editDate}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Dauer</span>
|
||||
<div class="duration-inputs">
|
||||
<label class="dur-field">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
class="dur-input"
|
||||
bind:value={editDurationH}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
/>
|
||||
<span class="dur-unit">h</span>
|
||||
</label>
|
||||
<label class="dur-field">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="dur-input"
|
||||
bind:value={editDurationM}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
/>
|
||||
<span class="dur-unit">m</span>
|
||||
</label>
|
||||
<label class="dur-field">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="dur-input"
|
||||
bind:value={editDurationS}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
/>
|
||||
<span class="dur-unit">s</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Projekt</span>
|
||||
<select
|
||||
class="prop-select"
|
||||
value={editProjectId ?? ''}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
handleProjectChange(val || null);
|
||||
}}
|
||||
>
|
||||
<option value="">Kein Projekt</option>
|
||||
{#each projects as project}
|
||||
<option value={project.id}>
|
||||
{project.name}
|
||||
{#if project.clientId}
|
||||
{@const client = clients.find((c) => c.id === project.clientId)}
|
||||
{#if client}
|
||||
· {client.name}{/if}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Abrechenbar</span>
|
||||
<button class="billable-toggle" class:active={editBillable} onclick={handleBillableChange}>
|
||||
{editBillable ? 'Ja' : 'Nein'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if selectedProject}
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Projekt</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="color-dot" style="background-color: {selectedProject.color}"></span>
|
||||
<span class="prop-value">{selectedProject.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedClient}
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Kunde</span>
|
||||
<span class="prop-value">{selectedClient.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="meta">
|
||||
{#if entry.createdAt}
|
||||
<span>Erstellt: {new Date(entry.createdAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
{#if entry.source?.app}
|
||||
<span>Quelle: {entry.source.app}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="danger-zone">
|
||||
{#if confirmDelete}
|
||||
<p class="confirm-text">Eintrag wirklich loschen?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn danger" onclick={deleteEntry}>Loschen</button>
|
||||
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
|
||||
<Trash size={14} /> Loschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.title-input {
|
||||
width: 100%;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #374151;
|
||||
padding: 0;
|
||||
}
|
||||
.title-input:focus {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .title-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .title-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Time range */
|
||||
.time-range {
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.prop-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.prop-value {
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .prop-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.prop-select,
|
||||
.prop-input {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.prop-select:hover,
|
||||
.prop-input:hover,
|
||||
.prop-select:focus,
|
||||
.prop-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .prop-select,
|
||||
:global(.dark) .prop-input {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .prop-select:hover,
|
||||
:global(.dark) .prop-input:hover,
|
||||
:global(.dark) .prop-select:focus,
|
||||
:global(.dark) .prop-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Duration inputs */
|
||||
.duration-inputs {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.dur-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.dur-input {
|
||||
width: 2.5rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.dur-input::-webkit-outer-spin-button,
|
||||
.dur-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.dur-input:hover,
|
||||
.dur-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .dur-input {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .dur-input:hover,
|
||||
:global(.dark) .dur-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.dur-unit {
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Billable toggle */
|
||||
.billable-toggle {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.billable-toggle.active {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
:global(.dark) .billable-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .billable-toggle.active {
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
/* Color dot */
|
||||
.color-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Meta & actions */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .meta {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.danger-zone {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #374151;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger-subtle {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +1,31 @@
|
|||
<!--
|
||||
Zitare — Workbench ListView
|
||||
Quote of the day with favorites count.
|
||||
Shows one quote at a time. Tap to cycle. Fav button inline.
|
||||
Supports tag drag-and-drop onto the current quote.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
|
||||
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
|
||||
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/zitare/queries';
|
||||
import { Heart } from '@manacore/shared-icons';
|
||||
import { dropTarget } from '@manacore/shared-ui/dnd';
|
||||
import type { TagDragData } from '@manacore/shared-ui/dnd';
|
||||
import { getTagsByIds } from '$lib/stores/tags.svelte';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalFavorite } from './types';
|
||||
import type { Quote } from '@zitare/content';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
let favorites = $state<LocalFavorite[]>([]);
|
||||
let quote = $state<Quote | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
quotesStore.initialize();
|
||||
quote = quotesStore.currentQuote;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
|
|
@ -21,38 +39,95 @@
|
|||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// Simple daily quote selection based on day of year
|
||||
const quotes = [
|
||||
{ text: 'Der Weg ist das Ziel.', author: 'Konfuzius' },
|
||||
{ text: 'Wer nicht wagt, der nicht gewinnt.', author: 'Sprichwort' },
|
||||
{
|
||||
text: 'In der Mitte von Schwierigkeiten liegen die Möglichkeiten.',
|
||||
author: 'Albert Einstein',
|
||||
},
|
||||
{ text: 'Es ist nicht genug zu wissen, man muss auch anwenden.', author: 'Goethe' },
|
||||
{ text: 'Handle, ehe du denkst. Nein — denke, ehe du handelst.', author: 'Mark Twain' },
|
||||
{
|
||||
text: 'Die Zukunft gehört denen, die an die Schönheit ihrer Träume glauben.',
|
||||
author: 'Eleanor Roosevelt',
|
||||
},
|
||||
{ text: 'Was immer du tun kannst oder träumst es zu können, fang damit an.', author: 'Goethe' },
|
||||
];
|
||||
|
||||
const dayOfYear = Math.floor(
|
||||
(Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000
|
||||
let favoritesAsDomain = $derived<Favorite[]>(
|
||||
favorites.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }))
|
||||
);
|
||||
const todayQuote = quotes[dayOfYear % quotes.length];
|
||||
|
||||
let currentFav = $derived(quote ? favorites.find((f) => f.quoteId === quote!.id) : undefined);
|
||||
let isFav = $derived(!!currentFav);
|
||||
let currentTagIds = $derived(currentFav?.tagIds ?? []);
|
||||
let currentTags = $derived(getTagsByIds(currentTagIds));
|
||||
|
||||
function nextQuote() {
|
||||
quotesStore.loadRandomQuote();
|
||||
quote = quotesStore.currentQuote;
|
||||
}
|
||||
|
||||
async function toggleFav(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (!quote) return;
|
||||
await favoritesStore.toggle(quote.id, favoritesAsDomain);
|
||||
}
|
||||
|
||||
async function handleTagDrop(tagData: TagDragData) {
|
||||
if (!quote) return;
|
||||
// Ensure quote is favorited first
|
||||
let fav = favorites.find((f) => f.quoteId === quote!.id);
|
||||
if (!fav) {
|
||||
await favoritesStore.add(quote.id);
|
||||
// Re-fetch to get the new favorite
|
||||
const all = await db.table<LocalFavorite>('zitareFavorites').toArray();
|
||||
fav = all.find((f) => f.quoteId === quote!.id && !f.deletedAt);
|
||||
if (!fav) return;
|
||||
}
|
||||
const current = fav.tagIds ?? [];
|
||||
if (!current.includes(tagData.id)) {
|
||||
await db.table('zitareFavorites').update(fav.id, {
|
||||
tagIds: [...current, tagData.id],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center justify-center gap-6 p-6">
|
||||
<div class="text-center">
|
||||
<blockquote class="text-lg font-light italic leading-relaxed text-white/80">
|
||||
«{todayQuote.text}»
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex h-full cursor-pointer flex-col items-center justify-center p-6"
|
||||
onclick={nextQuote}
|
||||
use:dropTarget={{
|
||||
accepts: ['tag'],
|
||||
onDrop: (p) => handleTagDrop(p.data as unknown as TagDragData),
|
||||
canDrop: (p) => !currentTagIds.includes((p.data as unknown as TagDragData).id),
|
||||
}}
|
||||
>
|
||||
{#if quote}
|
||||
<blockquote
|
||||
class="max-w-[280px] text-center text-base font-light italic leading-relaxed text-white/80"
|
||||
>
|
||||
«{quotesStore.getText(quote)}»
|
||||
</blockquote>
|
||||
<p class="mt-3 text-sm text-white/40">— {todayQuote.author}</p>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-white/40">— {quote.author}</p>
|
||||
|
||||
<div class="text-xs text-white/30">
|
||||
{favorites.length} gespeicherte Zitate
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
{#if currentTags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{#each currentTags as tag (tag.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] text-white/50"
|
||||
style="background: {tag.color}20; border: 1px solid {tag.color}30"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" style="background: {tag.color}"></span>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button onclick={toggleFav} class="mt-3 rounded-full p-1.5 transition-colors hover:bg-white/5">
|
||||
<Heart
|
||||
size={16}
|
||||
weight={isFav ? 'fill' : 'regular'}
|
||||
class="transition-colors {isFav ? 'text-red-400' : 'text-white/20 hover:text-white/40'}"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.mana-drop-target-hover) {
|
||||
outline: 2px solid rgba(139, 92, 246, 0.4);
|
||||
outline-offset: -2px;
|
||||
background: rgba(139, 92, 246, 0.06) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { BaseRecord } from '@manacore/local-store';
|
|||
|
||||
export interface LocalFavorite extends BaseRecord {
|
||||
quoteId: string;
|
||||
tagIds?: string[] | null;
|
||||
}
|
||||
|
||||
export interface LocalQuoteList extends BaseRecord {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
<!--
|
||||
Zitare — DetailView
|
||||
Full quote detail with category, source, author bio, share, favorite.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
|
||||
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
|
||||
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/zitare/queries';
|
||||
import { Heart, ShareNetwork, Info } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalFavorite } from '../types';
|
||||
import { QUOTES, type Quote, type Category } from '@zitare/content';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let quoteId = $derived(params.quoteId as string);
|
||||
|
||||
let favorites = $state<Favorite[]>([]);
|
||||
let showBio = $state(false);
|
||||
|
||||
let quote = $derived(QUOTES.find((q) => q.id === quoteId) ?? null);
|
||||
let isFav = $derived(quote ? checkIsFavorite(favorites, quote.id) : false);
|
||||
let quoteText = $derived(quote ? quotesStore.getText(quote) : '');
|
||||
|
||||
const categoryLabels: Record<Category, string> = {
|
||||
weisheit: 'Weisheit',
|
||||
motivation: 'Motivation',
|
||||
liebe: 'Liebe',
|
||||
leben: 'Leben',
|
||||
erfolg: 'Erfolg',
|
||||
glueck: 'Glück',
|
||||
freundschaft: 'Freundschaft',
|
||||
mut: 'Mut',
|
||||
hoffnung: 'Hoffnung',
|
||||
natur: 'Natur',
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
quotesStore.initialize();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const locals = await db.table<LocalFavorite>('zitareFavorites').toArray();
|
||||
return locals
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }));
|
||||
}).subscribe((val) => {
|
||||
favorites = val ?? [];
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
let authorBio = $derived(() => {
|
||||
if (!quote?.authorBio) return '';
|
||||
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
|
||||
return quote.authorBio[lang] || quote.authorBio.de || '';
|
||||
});
|
||||
|
||||
async function toggleFav() {
|
||||
if (!quote) return;
|
||||
await favoritesStore.toggle(quote.id, favorites);
|
||||
}
|
||||
|
||||
async function shareQuote() {
|
||||
if (!quote) return;
|
||||
const text = `"${quoteText}" — ${quote.author}`;
|
||||
if (navigator.share) {
|
||||
await navigator.share({ text, title: 'Zitare' });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !quote}
|
||||
<p class="empty">Zitat nicht gefunden</p>
|
||||
{:else}
|
||||
<!-- Quote -->
|
||||
<blockquote class="quote-text">
|
||||
“{quoteText}”
|
||||
</blockquote>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="author-row">
|
||||
<span class="author-name">— {quote.author}</span>
|
||||
{#if authorBio()}
|
||||
<button class="bio-btn" onclick={() => (showBio = !showBio)}>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showBio && authorBio()}
|
||||
<p class="author-bio">{authorBio()}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-row">
|
||||
<span class="category-badge">{categoryLabels[quote.category]}</span>
|
||||
{#if quote.source || quote.year}
|
||||
<span class="source-text">
|
||||
{#if quote.source}{quote.source}{/if}
|
||||
{#if quote.source && quote.year}
|
||||
·
|
||||
{/if}
|
||||
{#if quote.year}{quote.year}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="action-btn" class:fav-active={isFav} onclick={toggleFav}>
|
||||
<Heart size={18} weight={isFav ? 'fill' : 'regular'} />
|
||||
<span>{isFav ? 'Gespeichert' : 'Speichern'}</span>
|
||||
</button>
|
||||
<button class="action-btn" onclick={shareQuote}>
|
||||
<ShareNetwork size={18} />
|
||||
<span>Teilen</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .quote-text {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.author-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.author-name {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.bio-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.bio-btn:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.author-bio {
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.category-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
:global(.dark) .category-badge {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
.source-text {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .actions {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #6b7280;
|
||||
}
|
||||
.action-btn.fav-active {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .action-btn.fav-active {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,12 +4,7 @@
|
|||
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
|
||||
import { getAppEntry } from '$lib/components/workbench/app-registry';
|
||||
import { createAppSettingsStore } from '@manacore/shared-stores';
|
||||
import { DragPreview, dragSource } from '@manacore/shared-ui/dnd';
|
||||
import { useAllTags } from '$lib/stores/tags.svelte';
|
||||
|
||||
// ── Tags for drag & drop ───────────────────────────────
|
||||
const tagsQuery = useAllTags();
|
||||
let allTags = $derived(tagsQuery.value ?? []);
|
||||
import { DragPreview } from '@manacore/shared-ui/dnd';
|
||||
|
||||
// ── Persisted workbench state ───────────────────────────
|
||||
const DEFAULT_WIDTH = 480;
|
||||
|
|
@ -140,23 +135,6 @@
|
|||
<DragPreview />
|
||||
|
||||
<div class="workbench">
|
||||
{#if allTags.length > 0}
|
||||
<div class="tag-bar">
|
||||
{#each allTags as tag (tag.id)}
|
||||
<button
|
||||
class="tag-pill"
|
||||
use:dragSource={{
|
||||
type: 'tag',
|
||||
data: () => ({ id: tag.id, name: tag.name, color: tag.color }),
|
||||
}}
|
||||
>
|
||||
<span class="tag-dot" style="background: {tag.color}"></span>
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<PageCarousel
|
||||
pages={carouselPages}
|
||||
defaultWidth={DEFAULT_WIDTH}
|
||||
|
|
@ -197,51 +175,4 @@
|
|||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .tag-pill {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .tag-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.tag-pill:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
:global(.tag-pill.mana-drag-source-active) {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue