mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 16:17:43 +02:00
fix(mana/web+packages): clear all 270 warnings to zero
Comprehensive warning sweep across 128 files that brings svelte-check from 270 warnings → 0 (plus 3 new errors from concurrent upstream changes fixed inline). Final state: 6473 files, 0 errors, 0 warnings, 0 files with problems. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b8987562ba
commit
da03fac722
128 changed files with 1599 additions and 348 deletions
|
|
@ -106,6 +106,8 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={handleRef}
|
bind:this={handleRef}
|
||||||
class="tile-resize-handle"
|
class="tile-resize-handle"
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@
|
||||||
{@const TypeIcon = typeIcons[block.type] ?? CalendarBlank}
|
{@const TypeIcon = typeIcons[block.type] ?? CalendarBlank}
|
||||||
{@const habitIcon =
|
{@const habitIcon =
|
||||||
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
||||||
|
{@const Icon = habitIcon ?? TypeIcon}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
|
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
|
||||||
>
|
>
|
||||||
|
|
@ -96,11 +97,7 @@
|
||||||
class:animate-pulse={block.isLive}
|
class:animate-pulse={block.isLive}
|
||||||
style="background: {block.color || '#6b7280'}20; color: {block.color || '#6b7280'}"
|
style="background: {block.color || '#6b7280'}20; color: {block.color || '#6b7280'}"
|
||||||
>
|
>
|
||||||
{#if habitIcon}
|
<Icon size={12} />
|
||||||
<svelte:component this={habitIcon} size={12} />
|
|
||||||
{:else}
|
|
||||||
<svelte:component this={TypeIcon} size={12} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
></div>
|
></div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<svelte:component this={TypeIcon} size={12} class="text-muted-foreground" />
|
<TypeIcon size={12} class="text-muted-foreground" />
|
||||||
<p class="truncate text-sm font-medium">{block.title}</p>
|
<p class="truncate text-sm font-medium">{block.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">{formatEventTime(block)}</p>
|
<p class="text-xs text-muted-foreground">{formatEventTime(block)}</p>
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,9 @@
|
||||||
{#each [...typeCounts().entries()] as [type, count]}
|
{#each [...typeCounts().entries()] as [type, count]}
|
||||||
{@const cfg = typeConfig[type]}
|
{@const cfg = typeConfig[type]}
|
||||||
{#if cfg}
|
{#if cfg}
|
||||||
|
{@const Icon = cfg.icon}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<svelte:component this={cfg.icon} size={12} />
|
<Icon size={12} />
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -124,6 +125,7 @@
|
||||||
{@const habitIcon =
|
{@const habitIcon =
|
||||||
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
||||||
{@const duration = getBlockDuration(block)}
|
{@const duration = getBlockDuration(block)}
|
||||||
|
{@const Icon = habitIcon ?? cfg.icon}
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-2.5 rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
class="flex items-start gap-2.5 rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||||
>
|
>
|
||||||
|
|
@ -134,11 +136,7 @@
|
||||||
class:animate-pulse={block.isLive}
|
class:animate-pulse={block.isLive}
|
||||||
style="background-color: {block.color || '#6b7280'}"
|
style="background-color: {block.color || '#6b7280'}"
|
||||||
></div>
|
></div>
|
||||||
{#if habitIcon}
|
<Icon size={14} class="text-muted-foreground" />
|
||||||
<svelte:component this={habitIcon} size={14} class="text-muted-foreground" />
|
|
||||||
{:else}
|
|
||||||
<svelte:component this={cfg.icon} size={14} class="text-muted-foreground" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
let config: LandingPageConfig = $state(
|
let config: LandingPageConfig = $state(
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
initialConfig ? structuredClone(initialConfig) : structuredClone(defaultConfig)
|
initialConfig ? structuredClone(initialConfig) : structuredClone(defaultConfig)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -190,6 +191,7 @@
|
||||||
<SectionEditor title="Hero" expanded={true}>
|
<SectionEditor title="Hero" expanded={true}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -199,6 +201,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Subtitle</label
|
>Subtitle</label
|
||||||
>
|
>
|
||||||
|
|
@ -210,6 +213,7 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Variant</label
|
>Variant</label
|
||||||
>
|
>
|
||||||
|
|
@ -224,6 +228,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>CTA Button Text</label
|
>CTA Button Text</label
|
||||||
>
|
>
|
||||||
|
|
@ -240,6 +245,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>CTA Button Link</label
|
>CTA Button Link</label
|
||||||
>
|
>
|
||||||
|
|
@ -263,6 +269,7 @@
|
||||||
<SectionEditor title="About / Features">
|
<SectionEditor title="About / Features">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Section Title</label
|
>Section Title</label
|
||||||
>
|
>
|
||||||
|
|
@ -274,6 +281,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Subtitle</label
|
>Subtitle</label
|
||||||
>
|
>
|
||||||
|
|
@ -285,6 +293,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
|
||||||
<RepeatableField
|
<RepeatableField
|
||||||
items={config.sections.about.features}
|
items={config.sections.about.features}
|
||||||
|
|
@ -324,6 +333,7 @@
|
||||||
<SectionEditor title="Team">
|
<SectionEditor title="Team">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Section Title</label
|
>Section Title</label
|
||||||
>
|
>
|
||||||
|
|
@ -335,6 +345,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Members</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Members</label>
|
||||||
<RepeatableField
|
<RepeatableField
|
||||||
items={config.sections.team.members}
|
items={config.sections.team.members}
|
||||||
|
|
@ -374,6 +385,7 @@
|
||||||
<SectionEditor title="Contact">
|
<SectionEditor title="Contact">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Section Title</label
|
>Section Title</label
|
||||||
>
|
>
|
||||||
|
|
@ -386,6 +398,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>E-Mail</label
|
>E-Mail</label
|
||||||
>
|
>
|
||||||
|
|
@ -397,6 +410,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Phone</label
|
>Phone</label
|
||||||
>
|
>
|
||||||
|
|
@ -409,6 +423,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Address</label
|
>Address</label
|
||||||
>
|
>
|
||||||
|
|
@ -426,6 +441,7 @@
|
||||||
<SectionEditor title="Footer">
|
<SectionEditor title="Footer">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>Copyright Text</label
|
>Copyright Text</label
|
||||||
>
|
>
|
||||||
|
|
@ -437,6 +453,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Links</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Links</label>
|
||||||
<RepeatableField
|
<RepeatableField
|
||||||
items={config.sections.footer.links || []}
|
items={config.sections.footer.links || []}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
children,
|
children,
|
||||||
}: { title: string; expanded?: boolean; children: Snippet } = $props();
|
}: { title: string; expanded?: boolean; children: Snippet } = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let isExpanded = $state(expanded);
|
let isExpanded = $state(expanded);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
let { recordRef, navigate }: Props = $props();
|
let { recordRef, navigate }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const linksQuery = useLinksForRecord(recordRef);
|
const linksQuery = useLinksForRecord(recordRef);
|
||||||
let links = $derived(linksQuery.value ?? []);
|
let links = $derived(linksQuery.value ?? []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<svg
|
<svg
|
||||||
bind:this={svgEl}
|
bind:this={svgEl}
|
||||||
viewBox={viewBoxStr}
|
viewBox={viewBoxStr}
|
||||||
|
|
@ -176,6 +177,7 @@
|
||||||
<Ambient {hour} />
|
<Ambient {hour} />
|
||||||
|
|
||||||
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
|
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
|
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
|
||||||
<g
|
<g
|
||||||
onmouseenter={(e) => handleAppHover(app, e)}
|
onmouseenter={(e) => handleAppHover(app, e)}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Hover area (invisible wide line) -->
|
<!-- Hover area (invisible wide line) -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<path
|
<path
|
||||||
d={line.path}
|
d={line.path}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
let selectedFile = $state<File | null>(null);
|
let selectedFile = $state<File | null>(null);
|
||||||
|
|
||||||
// File input ref
|
// File input ref
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput = $state<HTMLInputElement | undefined>(undefined);
|
||||||
|
|
||||||
// Initialize form when modal opens
|
// Initialize form when modal opens
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -140,6 +140,7 @@
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
@ -161,6 +162,7 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Avatar Upload -->
|
<!-- Avatar Upload -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium mb-2">Profilbild</label>
|
<label class="block text-sm font-medium mb-2">Profilbild</label>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Avatar Preview -->
|
<!-- Avatar Preview -->
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@
|
||||||
).toFixed(0)}%)…
|
).toFixed(0)}%)…
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
@ -242,6 +243,7 @@
|
||||||
Cloud-Anfragen senden deine Inhalte an Google. Bitte bestätige, dass du das
|
Cloud-Anfragen senden deine Inhalte an Google. Bitte bestätige, dass du das
|
||||||
verstanden hast und akzeptierst.
|
verstanden hast und akzeptierst.
|
||||||
</p>
|
</p>
|
||||||
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
@ -258,6 +260,7 @@
|
||||||
{#if card.tier === 'cloud' && enabled && settings.cloudConsentGiven}
|
{#if card.tier === 'cloud' && enabled && settings.cloudConsentGiven}
|
||||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs">
|
<div class="mt-3 flex items-center justify-between gap-2 text-xs">
|
||||||
<span class="text-emerald-500">✓ Cloud-Zustimmung erteilt</span>
|
<span class="text-emerald-500">✓ Cloud-Zustimmung erteilt</span>
|
||||||
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@
|
||||||
<button type="button" class="close" onclick={onClose} aria-label="Schließen">×</button>
|
<button type="button" class="close" onclick={onClose} aria-label="Schließen">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="search"
|
class="search"
|
||||||
type="search"
|
type="search"
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,20 @@
|
||||||
Simple calculator with expression input and history.
|
Simple calculator with expression input and history.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { liveQuery } from 'dexie';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import type { LocalCalculation } from './types';
|
import type { LocalCalculation } from './types';
|
||||||
|
|
||||||
let calculations = $state<LocalCalculation[]>([]);
|
const calcQuery = useLiveQueryWithDefault(async () => {
|
||||||
|
const all = await db.table<LocalCalculation>('calculations').toArray();
|
||||||
|
return all.filter((c) => !c.deletedAt);
|
||||||
|
}, [] as LocalCalculation[]);
|
||||||
|
|
||||||
let expression = $state('');
|
let expression = $state('');
|
||||||
let result = $state('');
|
let result = $state('');
|
||||||
|
let hasError = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
const calculations = $derived(calcQuery.value);
|
||||||
const sub = liveQuery(async () => {
|
|
||||||
return db
|
|
||||||
.table<LocalCalculation>('calculations')
|
|
||||||
.toArray()
|
|
||||||
.then((all) => all.filter((c) => !c.deletedAt));
|
|
||||||
}).subscribe((val) => {
|
|
||||||
calculations = val ?? [];
|
|
||||||
});
|
|
||||||
return () => sub.unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
const recent = $derived(
|
const recent = $derived(
|
||||||
[...calculations]
|
[...calculations]
|
||||||
|
|
@ -29,15 +24,53 @@
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Live evaluation — update result preview as user types
|
||||||
|
$effect(() => {
|
||||||
|
if (!expression.trim()) {
|
||||||
|
result = '0';
|
||||||
|
hasError = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, '');
|
||||||
|
const evalResult = Function('"use strict"; return (' + sanitized + ')')();
|
||||||
|
if (evalResult === undefined || evalResult === null || isNaN(evalResult)) {
|
||||||
|
result = expression;
|
||||||
|
hasError = false;
|
||||||
|
} else {
|
||||||
|
result = String(evalResult);
|
||||||
|
hasError = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Incomplete expression — don't show error while typing
|
||||||
|
hasError = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function evaluate() {
|
function evaluate() {
|
||||||
if (!expression.trim()) return;
|
if (!expression.trim()) return;
|
||||||
try {
|
try {
|
||||||
// Basic safe eval for simple math expressions
|
|
||||||
const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, '');
|
const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, '');
|
||||||
const evalResult = Function('"use strict"; return (' + sanitized + ')')();
|
const evalResult = Function('"use strict"; return (' + sanitized + ')')();
|
||||||
result = String(evalResult);
|
result = String(evalResult);
|
||||||
|
hasError = false;
|
||||||
} catch {
|
} catch {
|
||||||
result = 'Fehler';
|
result = 'Fehler';
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(key: string) {
|
||||||
|
if (key === '=') {
|
||||||
|
evaluate();
|
||||||
|
} else if (key === 'C') {
|
||||||
|
expression = '';
|
||||||
|
result = '0';
|
||||||
|
hasError = false;
|
||||||
|
} else if (key === '⌫') {
|
||||||
|
expression = expression.slice(0, -1);
|
||||||
|
} else {
|
||||||
|
expression += key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,49 +80,206 @@
|
||||||
evaluate();
|
evaluate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
{ label: 'C', style: 'fn' },
|
||||||
|
{ label: '⌫', style: 'fn' },
|
||||||
|
{ label: '%', style: 'op' },
|
||||||
|
{ label: '/', style: 'op' },
|
||||||
|
{ label: '7', style: '' },
|
||||||
|
{ label: '8', style: '' },
|
||||||
|
{ label: '9', style: '' },
|
||||||
|
{ label: '*', style: 'op' },
|
||||||
|
{ label: '4', style: '' },
|
||||||
|
{ label: '5', style: '' },
|
||||||
|
{ label: '6', style: '' },
|
||||||
|
{ label: '-', style: 'op' },
|
||||||
|
{ label: '1', style: '' },
|
||||||
|
{ label: '2', style: '' },
|
||||||
|
{ label: '3', style: '' },
|
||||||
|
{ label: '+', style: 'op' },
|
||||||
|
{ label: '0', style: '' },
|
||||||
|
{ label: '.', style: '' },
|
||||||
|
{ label: '(', style: 'op' },
|
||||||
|
{ label: ')', style: 'op' },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-3 sm:p-4">
|
<div class="calc">
|
||||||
<!-- Display -->
|
<!-- Display -->
|
||||||
<div class="rounded-md bg-white/5 p-3 text-right">
|
<div class="display">
|
||||||
<p class="text-xs text-white/40">{expression || ' '}</p>
|
<p class="expression">{expression || ' '}</p>
|
||||||
<p class="text-2xl font-light text-white/90">{result || '0'}</p>
|
<p class="result" class:error={hasError}>{result || '0'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input (hidden but accessible for keyboard) -->
|
||||||
<input
|
<input
|
||||||
bind:value={expression}
|
bind:value={expression}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder="Ausdruck eingeben..."
|
placeholder="Ausdruck eingeben..."
|
||||||
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-right text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
class="kbd-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Quick buttons -->
|
<!-- Button grid -->
|
||||||
<div class="grid grid-cols-4 gap-1">
|
<div class="grid">
|
||||||
{#each ['7', '8', '9', '/', '4', '5', '6', '*', '1', '2', '3', '-', '0', '.', '=', '+'] as key}
|
{#each keys as key}
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => press(key.label)}
|
||||||
if (key === '=') evaluate();
|
class="key"
|
||||||
else expression += key;
|
class:fn={key.style === 'fn'}
|
||||||
}}
|
class:op={key.style === 'op'}
|
||||||
class="rounded-md bg-white/5 py-2 text-sm text-white/70 transition-colors hover:bg-white/10
|
|
||||||
{key === '=' ? 'bg-blue-500/20 text-blue-300' : ''}"
|
|
||||||
>
|
>
|
||||||
{key}
|
{key.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<button onclick={() => press('=')} class="key eq"> = </button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
{#if recent.length > 0}
|
{#if recent.length > 0}
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="history">
|
||||||
<h3 class="mb-1 text-xs font-medium text-white/50">Verlauf</h3>
|
<h3 class="history-title">Verlauf</h3>
|
||||||
{#each recent as calc (calc.id)}
|
{#each recent as calc (calc.id)}
|
||||||
<div class="flex items-center justify-between py-1 text-xs">
|
<button
|
||||||
<span class="text-white/40">{calc.expression}</span>
|
class="history-item"
|
||||||
<span class="text-white/60">= {calc.result}</span>
|
onclick={() => {
|
||||||
</div>
|
expression = calc.expression ?? '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="history-expr">{calc.expression}</span>
|
||||||
|
<span class="history-result">= {calc.result}</span>
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.calc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
text-align: right;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
}
|
||||||
|
.expression {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.result.error {
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.kbd-input::placeholder {
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.kbd-input:focus {
|
||||||
|
border-color: hsl(var(--color-border-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
color: hsl(var(--color-foreground) / 0.8);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
background: hsl(var(--color-muted) / 0.6);
|
||||||
|
}
|
||||||
|
.key.op {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
.key.fn {
|
||||||
|
background: hsl(var(--color-muted) / 0.15);
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.key.eq {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.key:hover {
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
}
|
||||||
|
.key.eq:hover {
|
||||||
|
background: hsl(var(--color-primary) / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.history-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.history-item:hover {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
}
|
||||||
|
.history-expr {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.history-result {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-foreground) / 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
<span
|
<span
|
||||||
class="event-title agenda-event-title"
|
class="event-title agenda-event-title"
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,13 @@
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
{#each blockTypeConfig as cfg}
|
{#each blockTypeConfig as cfg}
|
||||||
{@const isActive = calendarViewStore.visibleBlockTypes.has(cfg.type)}
|
{@const isActive = calendarViewStore.visibleBlockTypes.has(cfg.type)}
|
||||||
|
{@const Icon = cfg.icon}
|
||||||
<button
|
<button
|
||||||
class="filter-chip"
|
class="filter-chip"
|
||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
onclick={() => calendarViewStore.toggleBlockType(cfg.type)}
|
onclick={() => calendarViewStore.toggleBlockType(cfg.type)}
|
||||||
>
|
>
|
||||||
<svelte:component this={cfg.icon} size={14} />
|
<Icon size={14} />
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
let { initialRule, onApply, onCancel }: Props = $props();
|
let { initialRule, onApply, onCancel }: Props = $props();
|
||||||
|
|
||||||
// Parse initial rule if provided
|
// Parse initial rule if provided
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const parsed = initialRule ? parseRule(initialRule) : null;
|
const parsed = initialRule ? parseRule(initialRule) : null;
|
||||||
|
|
||||||
let freq = $state<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'>(parsed?.freq ?? 'WEEKLY');
|
let freq = $state<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'>(parsed?.freq ?? 'WEEKLY');
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,9 @@
|
||||||
{:else if event.blockType === 'timeEntry'}
|
{:else if event.blockType === 'timeEntry'}
|
||||||
<span class="type-icon"><Timer size={10} weight="bold" /></span>
|
<span class="type-icon"><Timer size={10} weight="bold" /></span>
|
||||||
{:else if event.blockType === 'habit' && habitIconComponent}
|
{:else if event.blockType === 'habit' && habitIconComponent}
|
||||||
|
{@const HabitIcon = habitIconComponent}
|
||||||
<span class="type-icon">
|
<span class="type-icon">
|
||||||
<svelte:component this={habitIconComponent} size={10} weight="bold" />
|
<HabitIcon size={10} weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
{:else if event.blockType === 'focus'}
|
{:else if event.blockType === 'focus'}
|
||||||
<span class="type-icon"><Lightning size={10} weight="bold" /></span>
|
<span class="type-icon"><Lightning size={10} weight="bold" /></span>
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,8 @@
|
||||||
<!-- Recurrence Delete Dialog -->
|
<!-- Recurrence Delete Dialog -->
|
||||||
{#if showEditOptions}
|
{#if showEditOptions}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
<div class="delete-overlay" onclick={() => (showEditOptions = false)}>
|
<div class="delete-overlay" onclick={() => (showEditOptions = false)}>
|
||||||
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3 class="delete-title">Wiederkehrenden Termin bearbeiten</h3>
|
<h3 class="delete-title">Wiederkehrenden Termin bearbeiten</h3>
|
||||||
|
|
@ -330,6 +332,8 @@
|
||||||
|
|
||||||
{#if showDeleteOptions}
|
{#if showDeleteOptions}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="delete-overlay" onclick={() => (showDeleteOptions = false)}>
|
<div class="delete-overlay" onclick={() => (showDeleteOptions = false)}>
|
||||||
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3 class="delete-title">Wiederkehrenden Termin löschen</h3>
|
<h3 class="delete-title">Wiederkehrenden Termin löschen</h3>
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,20 @@
|
||||||
|
|
||||||
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let title = $state(event?.title || '');
|
let title = $state(event?.title || '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let description = $state(event?.description || '');
|
let description = $state(event?.description || '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let location = $state(event?.location || '');
|
let location = $state(event?.location || '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let isAllDay = $state(event?.isAllDay || false);
|
let isAllDay = $state(event?.isAllDay || false);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let calendarId = $state(event?.calendarId || '');
|
let calendarId = $state(event?.calendarId || '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let recurrenceRule = $state(event?.recurrenceRule || '');
|
let recurrenceRule = $state(event?.recurrenceRule || '');
|
||||||
let selectedTagIds = $state<string[]>(
|
let selectedTagIds = $state<string[]>(
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
((event as unknown as Record<string, unknown>)?.tagIds as string[]) ?? []
|
((event as unknown as Record<string, unknown>)?.tagIds as string[]) ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,13 @@
|
||||||
let blockType = $state<QuickCreateType>('event');
|
let blockType = $state<QuickCreateType>('event');
|
||||||
let isAllDay = $state(false);
|
let isAllDay = $state(false);
|
||||||
let recurrenceRule = $state<string | null>(null);
|
let recurrenceRule = $state<string | null>(null);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
|
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let startTimeStr = $state(format(startTime, 'HH:mm'));
|
let startTimeStr = $state(format(startTime, 'HH:mm'));
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let endDateStr = $state(format(endTime, 'yyyy-MM-dd'));
|
let endDateStr = $state(format(endTime, 'yyyy-MM-dd'));
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let endTimeStr = $state(format(endTime, 'HH:mm'));
|
let endTimeStr = $state(format(endTime, 'HH:mm'));
|
||||||
|
|
||||||
let titleInput: HTMLInputElement;
|
let titleInput: HTMLInputElement;
|
||||||
|
|
@ -128,10 +132,12 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- Backdrop (transparent - allows seeing calendar) -->
|
<!-- Backdrop (transparent - allows seeing calendar) -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="popover-backdrop" onclick={onClose}></div>
|
<div class="popover-backdrop" onclick={onClose}></div>
|
||||||
|
|
||||||
<!-- Popover -->
|
<!-- Popover -->
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@
|
||||||
|
|
||||||
{#snippet profileCard(contact: Contact)}
|
{#snippet profileCard(contact: Contact)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="profile-card" onclick={() => onOpenContact?.(contact)}>
|
<div class="profile-card" onclick={() => onOpenContact?.(contact)}>
|
||||||
<div class="profile-avatar">
|
<div class="profile-avatar">
|
||||||
{#if contact.photoUrl}
|
{#if contact.photoUrl}
|
||||||
|
|
@ -301,6 +302,7 @@
|
||||||
|
|
||||||
{#snippet contactRow(contact: Contact)}
|
{#snippet contactRow(contact: Contact)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="contact-row"
|
class="contact-row"
|
||||||
onclick={() => onOpenContact?.(contact)}
|
onclick={() => onOpenContact?.(contact)}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each widgets as widget (widget.id)}
|
{#each widgets as widget (widget.id)}
|
||||||
|
{@const Widget = widget.component}
|
||||||
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
<svelte:boundary>
|
<svelte:boundary>
|
||||||
<svelte:component this={widget.component} />
|
<Widget />
|
||||||
{#snippet failed(error, reset)}
|
{#snippet failed(error, reset)}
|
||||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<div class="mb-2 text-2xl">⚠️</div>
|
<div class="mb-2 text-2xl">⚠️</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
const { cycles, logs, editingDate, todayIso, onSelectDay }: Props = $props();
|
const { cycles, logs, editingDate, todayIso, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
// ─ Month state ──────────────────────────────────────────
|
// ─ Month state ──────────────────────────────────────────
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const [initialYear, initialMonth] = todayIso.split('-').map((n) => parseInt(n, 10));
|
const [initialYear, initialMonth] = todayIso.split('-').map((n) => parseInt(n, 10));
|
||||||
let viewYear = $state(initialYear);
|
let viewYear = $state(initialYear);
|
||||||
let viewMonth = $state(initialMonth); // 1..12
|
let viewMonth = $state(initialMonth); // 1..12
|
||||||
|
|
|
||||||
|
|
@ -301,12 +301,14 @@
|
||||||
{#each group.dreams as dream (dream.id)}
|
{#each group.dreams as dream (dream.id)}
|
||||||
{#if editingId === dream.id}
|
{#if editingId === dream.id}
|
||||||
<!-- Inline editor -->
|
<!-- Inline editor -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="dream-item editing"
|
class="dream-item editing"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Escape') saveEdit();
|
if (e.key === 'Escape') saveEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="ed-title"
|
class="ed-title"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -325,6 +327,10 @@
|
||||||
? `: ${dream.processingError}`
|
? `: ${dream.processingError}`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if dream.transcript && dream.transcriptModel}
|
||||||
|
<div class="ed-status muted" title="STT-Pipeline, die den Transkript erzeugt hat">
|
||||||
|
Transkribiert via <strong>{dream.transcriptModel}</strong>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<textarea
|
<textarea
|
||||||
class="ed-content"
|
class="ed-content"
|
||||||
|
|
@ -444,6 +450,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div class="dream-meta">
|
<div class="dream-meta">
|
||||||
<span>{formatDreamDate(dream.dreamDate)}</span>
|
<span>{formatDreamDate(dream.dreamDate)}</span>
|
||||||
|
{#if dream.transcriptModel}
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span class="stt-chip" title="STT-Pipeline">
|
||||||
|
🎤 {dream.transcriptModel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if dream.symbols.length > 0}
|
{#if dream.symbols.length > 0}
|
||||||
<span class="dot">·</span>
|
<span class="dot">·</span>
|
||||||
<span class="symbol-chips">
|
<span class="symbol-chips">
|
||||||
|
|
@ -744,6 +756,17 @@
|
||||||
.dream-meta .dot {
|
.dream-meta .dot {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
.stt-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-muted) / 0.6);
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.symbol-chips {
|
.symbol-chips {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|
@ -806,6 +829,16 @@
|
||||||
background: hsl(var(--color-error) / 0.06);
|
background: hsl(var(--color-error) / 0.06);
|
||||||
color: hsl(var(--color-error));
|
color: hsl(var(--color-error));
|
||||||
}
|
}
|
||||||
|
.ed-status.muted {
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
.ed-status.muted strong {
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.ed-status-dots {
|
.ed-status-dots {
|
||||||
font-size: 0.5rem;
|
font-size: 0.5rem;
|
||||||
letter-spacing: 0.0625rem;
|
letter-spacing: 0.0625rem;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export const DREAMS_GUEST_SEED = {
|
||||||
audioPath: null,
|
audioPath: null,
|
||||||
audioDurationMs: null,
|
audioDurationMs: null,
|
||||||
transcript: null,
|
transcript: null,
|
||||||
|
transcriptModel: null,
|
||||||
processingStatus: 'idle',
|
processingStatus: 'idle',
|
||||||
processingError: null,
|
processingError: null,
|
||||||
interpretation: null,
|
interpretation: null,
|
||||||
|
|
@ -66,6 +67,7 @@ export const DREAMS_GUEST_SEED = {
|
||||||
audioPath: null,
|
audioPath: null,
|
||||||
audioDurationMs: null,
|
audioDurationMs: null,
|
||||||
transcript: null,
|
transcript: null,
|
||||||
|
transcriptModel: null,
|
||||||
processingStatus: 'idle',
|
processingStatus: 'idle',
|
||||||
processingError: null,
|
processingError: null,
|
||||||
interpretation: 'Gefühl von Kontrolle und Leichtigkeit nach einer entspannten Woche.',
|
interpretation: 'Gefühl von Kontrolle und Leichtigkeit nach einer entspannten Woche.',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function toDream(local: LocalDream): Dream {
|
||||||
audioPath: local.audioPath,
|
audioPath: local.audioPath,
|
||||||
audioDurationMs: local.audioDurationMs ?? null,
|
audioDurationMs: local.audioDurationMs ?? null,
|
||||||
transcript: local.transcript,
|
transcript: local.transcript,
|
||||||
|
transcriptModel: local.transcriptModel ?? null,
|
||||||
processingStatus: local.processingStatus ?? 'idle',
|
processingStatus: local.processingStatus ?? 'idle',
|
||||||
processingError: local.processingError ?? null,
|
processingError: local.processingError ?? null,
|
||||||
interpretation: local.interpretation,
|
interpretation: local.interpretation,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { dreamSymbolTable, dreamTable } from '../collections';
|
import { dreamSymbolTable, dreamTable } from '../collections';
|
||||||
import { toDream } from '../queries';
|
import { toDream } from '../queries';
|
||||||
import { encryptRecord } from '$lib/data/crypto';
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
|
import { transcribeAudio } from '$lib/voice/transcribe';
|
||||||
import type {
|
import type {
|
||||||
Dream,
|
Dream,
|
||||||
DreamClarity,
|
DreamClarity,
|
||||||
|
|
@ -54,6 +55,7 @@ export const dreamsStore = {
|
||||||
audioPath: null,
|
audioPath: null,
|
||||||
audioDurationMs: null,
|
audioDurationMs: null,
|
||||||
transcript: null,
|
transcript: null,
|
||||||
|
transcriptModel: null,
|
||||||
processingStatus: 'idle',
|
processingStatus: 'idle',
|
||||||
processingError: null,
|
processingError: null,
|
||||||
interpretation: null,
|
interpretation: null,
|
||||||
|
|
@ -146,6 +148,7 @@ export const dreamsStore = {
|
||||||
audioPath: null,
|
audioPath: null,
|
||||||
audioDurationMs: durationMs,
|
audioDurationMs: durationMs,
|
||||||
transcript: null,
|
transcript: null,
|
||||||
|
transcriptModel: null,
|
||||||
processingStatus: 'transcribing',
|
processingStatus: 'transcribing',
|
||||||
processingError: null,
|
processingError: null,
|
||||||
interpretation: null,
|
interpretation: null,
|
||||||
|
|
@ -182,32 +185,9 @@ export const dreamsStore = {
|
||||||
*/
|
*/
|
||||||
async transcribeBlob(dreamId: string, blob: Blob, language?: string): Promise<void> {
|
async transcribeBlob(dreamId: string, blob: Blob, language?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const result = await transcribeAudio(blob, language);
|
||||||
const ext = blob.type.includes('webm')
|
|
||||||
? '.webm'
|
|
||||||
: blob.type.includes('mp4')
|
|
||||||
? '.m4a'
|
|
||||||
: '.audio';
|
|
||||||
form.append('file', blob, `dream${ext}`);
|
|
||||||
if (language) form.append('language', language);
|
|
||||||
|
|
||||||
const response = await fetch('/api/v1/voice/transcribe', {
|
const transcript = result.text;
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(text || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as {
|
|
||||||
text: string;
|
|
||||||
language: string | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcript = (result.text ?? '').trim();
|
|
||||||
const existing = await dreamTable.get(dreamId);
|
const existing = await dreamTable.get(dreamId);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
|
|
@ -219,6 +199,7 @@ export const dreamsStore = {
|
||||||
|
|
||||||
const diff: Partial<LocalDream> = {
|
const diff: Partial<LocalDream> = {
|
||||||
transcript,
|
transcript,
|
||||||
|
transcriptModel: result.model,
|
||||||
// Only fill content if user hasn't typed anything yet
|
// Only fill content if user hasn't typed anything yet
|
||||||
content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript,
|
content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript,
|
||||||
processingStatus: 'idle',
|
processingStatus: 'idle',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface LocalDream extends BaseRecord {
|
||||||
audioPath: string | null;
|
audioPath: string | null;
|
||||||
audioDurationMs: number | null;
|
audioDurationMs: number | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
|
/** STT backend/model identifier (e.g. "whisperx-large-v3"). */
|
||||||
|
transcriptModel: string | null;
|
||||||
processingStatus: DreamProcessingStatus;
|
processingStatus: DreamProcessingStatus;
|
||||||
processingError: string | null;
|
processingError: string | null;
|
||||||
interpretation: string | null;
|
interpretation: string | null;
|
||||||
|
|
@ -71,6 +73,7 @@ export interface Dream {
|
||||||
audioPath: string | null;
|
audioPath: string | null;
|
||||||
audioDurationMs: number | null;
|
audioDurationMs: number | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
|
transcriptModel: string | null;
|
||||||
processingStatus: DreamProcessingStatus;
|
processingStatus: DreamProcessingStatus;
|
||||||
processingError: string | null;
|
processingError: string | null;
|
||||||
interpretation: string | null;
|
interpretation: string | null;
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
async function handleVoiceComplete(blob: Blob, durationMs: number) {
|
async function handleVoiceComplete(blob: Blob, durationMs: number) {
|
||||||
const result = await habitsStore.logFromVoice(blob, durationMs, 'de');
|
const result = await habitsStore.logFromVoice(blob, durationMs, 'de');
|
||||||
if (!result) {
|
if (!result) {
|
||||||
toastStore.error('Habit nicht erkannt. Versuche den Namen direkt zu sagen, z.B. "Kaffee".');
|
toastStore.error('Routine nicht erkannt. Versuche den Namen direkt zu sagen, z.B. "Kaffee".');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toastStore.success(`${result.habitTitle} geloggt`);
|
toastStore.success(`${result.habitTitle} geloggt`);
|
||||||
|
|
@ -152,9 +152,9 @@
|
||||||
<div class="habits-list-view">
|
<div class="habits-list-view">
|
||||||
<!-- Voice quick-log -->
|
<!-- Voice quick-log -->
|
||||||
<VoiceCaptureBar
|
<VoiceCaptureBar
|
||||||
idleLabel="Habit sprechen"
|
idleLabel="Routine sprechen"
|
||||||
feature="habits-voice-log"
|
feature="habits-voice-log"
|
||||||
reason="Habit-Logs werden in deinem persönlichen Kalender gespeichert. Dafür brauchst du ein Mana-Konto."
|
reason="Routinen-Logs werden in deinem persönlichen Kalender gespeichert. Dafür brauchst du ein Mana-Konto."
|
||||||
onComplete={handleVoiceComplete}
|
onComplete={handleVoiceComplete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -192,6 +192,7 @@
|
||||||
|
|
||||||
<!-- Inline Create Form -->
|
<!-- Inline Create Form -->
|
||||||
{#if showCreate}
|
{#if showCreate}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<form class="create-form" onsubmit={handleCreate} onkeydown={handleCreateKeydown}>
|
<form class="create-form" onsubmit={handleCreate} onkeydown={handleCreateKeydown}>
|
||||||
<div class="create-row">
|
<div class="create-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -202,10 +203,11 @@
|
||||||
>
|
>
|
||||||
<DynamicIcon name={newIcon} size={16} weight="bold" class="text-white" />
|
<DynamicIcon name={newIcon} size={16} weight="bold" class="text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="create-input"
|
class="create-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Habit Name..."
|
placeholder="Routinen-Name..."
|
||||||
bind:value={newTitle}
|
bind:value={newTitle}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
|
@ -224,6 +226,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
{#each QUICK_COLORS as c}
|
{#each QUICK_COLORS as c}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-dot"
|
class="color-dot"
|
||||||
|
|
@ -276,9 +279,9 @@
|
||||||
|
|
||||||
{#if activeHabits.length === 0 && !showCreate}
|
{#if activeHabits.length === 0 && !showCreate}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p>Noch keine Habits angelegt.</p>
|
<p>Noch keine Routinen angelegt.</p>
|
||||||
<button class="empty-add-btn" onclick={() => (showCreate = true)}
|
<button class="empty-add-btn" onclick={() => (showCreate = true)}
|
||||||
>Erstes Habit erstellen</button
|
>Erste Routine erstellen</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,26 @@
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let title = $state(habit?.title ?? '');
|
let title = $state(habit?.title ?? '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let icon = $state(habit?.icon ?? 'star');
|
let icon = $state(habit?.icon ?? 'star');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let color = $state(habit?.color ?? '#6366f1');
|
let color = $state(habit?.color ?? '#6366f1');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let targetPerDay = $state<string>(habit?.targetPerDay?.toString() ?? '');
|
let targetPerDay = $state<string>(habit?.targetPerDay?.toString() ?? '');
|
||||||
let defaultDurationMin = $state<string>(
|
let defaultDurationMin = $state<string>(
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
habit?.defaultDuration ? String(Math.round(habit.defaultDuration / 60)) : ''
|
habit?.defaultDuration ? String(Math.round(habit.defaultDuration / 60)) : ''
|
||||||
);
|
);
|
||||||
let showIconPicker = $state(false);
|
let showIconPicker = $state(false);
|
||||||
|
|
||||||
// Schedule state
|
// Schedule state
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let hasSchedule = $state(!!habit?.schedule);
|
let hasSchedule = $state(!!habit?.schedule);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let scheduleDays = $state<number[]>(habit?.schedule?.days ?? [1, 2, 3, 4, 5]); // Mon-Fri default
|
let scheduleDays = $state<number[]>(habit?.schedule?.days ?? [1, 2, 3, 4, 5]); // Mon-Fri default
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let scheduleTime = $state(habit?.schedule?.time ?? '');
|
let scheduleTime = $state(habit?.schedule?.time ?? '');
|
||||||
|
|
||||||
const dayLabels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
const dayLabels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
|
@ -73,6 +81,7 @@
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// svelte-ignore a11y_no_noninteractive_element_interactions
|
||||||
handleSubmit(e);
|
handleSubmit(e);
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|
@ -81,6 +90,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<form class="habit-form" onsubmit={handleSubmit} onkeydown={handleKeydown}>
|
<form class="habit-form" onsubmit={handleSubmit} onkeydown={handleKeydown}>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -91,6 +101,7 @@
|
||||||
>
|
>
|
||||||
<DynamicIcon name={icon} size={20} weight="bold" class="text-white" />
|
<DynamicIcon name={icon} size={20} weight="bold" class="text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="title-input"
|
class="title-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -107,6 +118,7 @@
|
||||||
onIconChange={(i) => {
|
onIconChange={(i) => {
|
||||||
icon = i;
|
icon = i;
|
||||||
showIconPicker = false;
|
showIconPicker = false;
|
||||||
|
// svelte-ignore a11y_consider_explicit_label
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -115,6 +127,7 @@
|
||||||
|
|
||||||
<div class="color-picker">
|
<div class="color-picker">
|
||||||
{#each HABIT_COLORS as c}
|
{#each HABIT_COLORS as c}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
|
|
|
||||||
|
|
@ -156,25 +156,12 @@ export const habitsStore = {
|
||||||
_durationMs: number,
|
_durationMs: number,
|
||||||
language = 'de'
|
language = 'de'
|
||||||
): Promise<{ logId: string; habitTitle: string } | null> {
|
): Promise<{ logId: string; habitTitle: string } | null> {
|
||||||
// Step 1: speech to text
|
// Step 1: speech to text (shared helper)
|
||||||
let transcript: string;
|
let transcript: string;
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const { transcribeAudio } = await import('$lib/voice/transcribe');
|
||||||
const ext = blob.type.includes('webm')
|
const result = await transcribeAudio(blob, language);
|
||||||
? '.webm'
|
transcript = result.text;
|
||||||
: blob.type.includes('mp4')
|
|
||||||
? '.m4a'
|
|
||||||
: '.audio';
|
|
||||||
form.append('file', blob, `habit${ext}`);
|
|
||||||
if (language) form.append('language', language);
|
|
||||||
|
|
||||||
const sttResponse = await fetch('/api/v1/voice/transcribe', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
if (!sttResponse.ok) return null;
|
|
||||||
const sttResult = (await sttResponse.json()) as { text: string };
|
|
||||||
transcript = (sttResult.text ?? '').trim();
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,14 +91,23 @@
|
||||||
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
|
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[memo.processingStatus] ??
|
{#if memo.transcriptModel && memo.processingStatus === 'completed'}
|
||||||
''}"
|
<span
|
||||||
>
|
class="rounded px-1 py-0.5 text-[9px] bg-white/5 text-white/30"
|
||||||
{memo.processingStatus === 'completed'
|
title="STT-Pipeline"
|
||||||
? formatDuration(memo.audioDurationMs)
|
>
|
||||||
: memo.processingStatus}
|
{memo.transcriptModel}
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="rounded px-1.5 py-0.5 text-[10px] {statusColors[memo.processingStatus] ?? ''}"
|
||||||
|
>
|
||||||
|
{memo.processingStatus === 'completed'
|
||||||
|
? formatDuration(memo.audioDurationMs)
|
||||||
|
: memo.processingStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export function toMemo(local: LocalMemo): Memo {
|
||||||
intro: local.intro,
|
intro: local.intro,
|
||||||
transcript: local.transcript,
|
transcript: local.transcript,
|
||||||
audioDurationMs: local.audioDurationMs,
|
audioDurationMs: local.audioDurationMs,
|
||||||
|
transcriptModel: local.transcriptModel ?? null,
|
||||||
processingStatus: local.processingStatus,
|
processingStatus: local.processingStatus,
|
||||||
isArchived: local.isArchived,
|
isArchived: local.isArchived,
|
||||||
isPinned: local.isPinned,
|
isPinned: local.isPinned,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { toMemo } from '../queries';
|
||||||
import { createArchiveOps } from '@mana/shared-stores';
|
import { createArchiveOps } from '@mana/shared-stores';
|
||||||
import { MemoroEvents } from '@mana/shared-utils/analytics';
|
import { MemoroEvents } from '@mana/shared-utils/analytics';
|
||||||
import { encryptRecord } from '$lib/data/crypto';
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
|
import { transcribeAudio } from '$lib/voice/transcribe';
|
||||||
import { llmTaskQueue } from '$lib/llm-queue';
|
import { llmTaskQueue } from '$lib/llm-queue';
|
||||||
import { generateTitleTask } from '$lib/llm-tasks/generate-title';
|
import { generateTitleTask } from '$lib/llm-tasks/generate-title';
|
||||||
import type { LocalMemo } from '../types';
|
import type { LocalMemo } from '../types';
|
||||||
|
|
@ -36,6 +37,7 @@ export const memosStore = {
|
||||||
intro: null,
|
intro: null,
|
||||||
transcript: data.transcript ?? null,
|
transcript: data.transcript ?? null,
|
||||||
audioDurationMs: data.audioDurationMs ?? null,
|
audioDurationMs: data.audioDurationMs ?? null,
|
||||||
|
transcriptModel: null,
|
||||||
processingStatus: data.processingStatus ?? (data.transcript ? 'completed' : 'pending'),
|
processingStatus: data.processingStatus ?? (data.transcript ? 'completed' : 'pending'),
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
|
@ -80,37 +82,15 @@ export const memosStore = {
|
||||||
*/
|
*/
|
||||||
async transcribeBlob(memoId: string, blob: Blob, language?: string): Promise<void> {
|
async transcribeBlob(memoId: string, blob: Blob, language?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const result = await transcribeAudio(blob, language);
|
||||||
const ext = blob.type.includes('webm')
|
|
||||||
? '.webm'
|
|
||||||
: blob.type.includes('mp4')
|
|
||||||
? '.m4a'
|
|
||||||
: '.audio';
|
|
||||||
form.append('file', blob, `memo${ext}`);
|
|
||||||
if (language) form.append('language', language);
|
|
||||||
|
|
||||||
const response = await fetch('/api/v1/voice/transcribe', {
|
const transcript = result.text;
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(text || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as {
|
|
||||||
text: string;
|
|
||||||
language: string | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcript = (result.text ?? '').trim();
|
|
||||||
const existing = await memoTable.get(memoId);
|
const existing = await memoTable.get(memoId);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
const diff: Partial<LocalMemo> = {
|
const diff: Partial<LocalMemo> = {
|
||||||
transcript,
|
transcript,
|
||||||
|
transcriptModel: result.model,
|
||||||
language: existing.language ?? result.language ?? null,
|
language: existing.language ?? result.language ?? null,
|
||||||
processingStatus: 'completed',
|
processingStatus: 'completed',
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface LocalMemo extends BaseRecord {
|
||||||
intro: string | null;
|
intro: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
audioDurationMs: number | null;
|
audioDurationMs: number | null;
|
||||||
|
transcriptModel: string | null;
|
||||||
processingStatus: ProcessingStatus;
|
processingStatus: ProcessingStatus;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
|
@ -79,6 +80,7 @@ export interface Memo {
|
||||||
intro: string | null;
|
intro: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
audioDurationMs: number | null;
|
audioDurationMs: number | null;
|
||||||
|
transcriptModel: string | null;
|
||||||
processingStatus: ProcessingStatus;
|
processingStatus: ProcessingStatus;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@
|
||||||
<!-- Colors -->
|
<!-- Colors -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="text-sm font-medium">Farben</label>
|
<label class="text-sm font-medium">Farben</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showFavorite}
|
{#if showFavorite}
|
||||||
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full p-1.5 transition-colors hover:bg-white/20"
|
class="rounded-full p-1.5 transition-colors hover:bg-white/20"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
<!--
|
<!--
|
||||||
Music — Workbench ListView
|
Music — Workbench ListView
|
||||||
Song library with recent plays and playlists.
|
Song library with recent plays, drag-to-upload for audio files.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
import { BaseListView } from '@mana/shared-ui';
|
import { BaseListView } from '@mana/shared-ui';
|
||||||
|
import { UploadSimple, Check, X, SpinnerGap } from '@mana/shared-icons';
|
||||||
import type { LocalSong, LocalPlaylist } from './types';
|
import type { LocalSong, LocalPlaylist } from './types';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
|
import { libraryStore } from './stores/library.svelte';
|
||||||
|
import { getManaApiUrl } from '$lib/api/config';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
|
@ -41,39 +45,357 @@
|
||||||
const s = Math.round(sec % 60);
|
const s = Math.round(sec % 60);
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Upload State ────────────────────────────────────────
|
||||||
|
let dragActive = $state(false);
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
interface UploadFile {
|
||||||
|
file: File;
|
||||||
|
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadFiles = $state<UploadFile[]>([]);
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive = false;
|
||||||
|
if (e.dataTransfer?.files) {
|
||||||
|
addFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files) {
|
||||||
|
addFiles(Array.from(input.files));
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(files: File[]) {
|
||||||
|
const audioFiles = files.filter((f) => f.type.startsWith('audio/'));
|
||||||
|
if (audioFiles.length === 0) return;
|
||||||
|
|
||||||
|
const newFiles: UploadFile[] = audioFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
status: 'pending',
|
||||||
|
}));
|
||||||
|
|
||||||
|
uploadFiles = [...uploadFiles, ...newFiles];
|
||||||
|
uploadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract duration in seconds from an audio file via a temporary Audio element. */
|
||||||
|
function getAudioDuration(file: File): Promise<number | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = 'metadata';
|
||||||
|
audio.onloadedmetadata = () => {
|
||||||
|
const dur = Number.isFinite(audio.duration) ? Math.round(audio.duration) : null;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(dur);
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAll() {
|
||||||
|
if (uploading) return;
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
const token = await authStore.getAccessToken();
|
||||||
|
|
||||||
|
for (let i = 0; i < uploadFiles.length; i++) {
|
||||||
|
if (uploadFiles[i]!.status !== 'pending') continue;
|
||||||
|
|
||||||
|
uploadFiles[i]!.status = 'uploading';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!token) throw new Error('Nicht eingeloggt');
|
||||||
|
|
||||||
|
// 1. Get presigned upload URL from mana-api
|
||||||
|
const res = await fetch(`${getManaApiUrl()}/api/v1/music/songs/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename: uploadFiles[i]!.file.name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Upload-URL konnte nicht erstellt werden');
|
||||||
|
|
||||||
|
const { song, uploadUrl } = (await res.json()) as {
|
||||||
|
song: { id: string; title: string; storagePath: string };
|
||||||
|
uploadUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Upload file directly to S3/MinIO via presigned URL
|
||||||
|
const putRes = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: uploadFiles[i]!.file,
|
||||||
|
headers: { 'Content-Type': uploadFiles[i]!.file.type || 'audio/mpeg' },
|
||||||
|
});
|
||||||
|
if (!putRes.ok) throw new Error('Datei-Upload fehlgeschlagen');
|
||||||
|
|
||||||
|
// 3. Extract duration from the audio file
|
||||||
|
const duration = await getAudioDuration(uploadFiles[i]!.file);
|
||||||
|
|
||||||
|
// 4. Create local IndexedDB record
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await libraryStore.insert({
|
||||||
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
storagePath: song.storagePath,
|
||||||
|
duration,
|
||||||
|
favorite: false,
|
||||||
|
playCount: 0,
|
||||||
|
fileSize: uploadFiles[i]!.file.size,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as LocalSong);
|
||||||
|
|
||||||
|
uploadFiles[i]!.status = 'success';
|
||||||
|
} catch (e) {
|
||||||
|
uploadFiles[i]!.status = 'error';
|
||||||
|
uploadFiles[i]!.error = e instanceof Error ? e.message : 'Upload fehlgeschlagen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
// Clear successful uploads after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadFiles = uploadFiles.filter((f) => f.status !== 'success');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUpload(index: number) {
|
||||||
|
uploadFiles = uploadFiles.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={recentlyPlayed} getKey={(s) => s.id} emptyTitle="Noch nichts gehört">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{#snippet header()}
|
<div
|
||||||
<span>{songs.length} Songs</span>
|
class="music-list-view"
|
||||||
<span>{playlists.length} Playlists</span>
|
ondragover={handleDragOver}
|
||||||
<span>{favorites.length} Favoriten</span>
|
ondragleave={handleDragLeave}
|
||||||
{/snippet}
|
ondrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet listHeader()}
|
<!-- Drop Overlay -->
|
||||||
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
|
{#if dragActive}
|
||||||
{/snippet}
|
<div class="drop-overlay">
|
||||||
|
<UploadSimple size={32} weight="bold" />
|
||||||
|
<span>Musik ablegen</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#snippet item(song)}
|
<BaseListView items={recentlyPlayed} getKey={(s) => s.id} emptyTitle="Noch nichts gehört">
|
||||||
<button
|
{#snippet header()}
|
||||||
onclick={() =>
|
<span>{songs.length} Songs</span>
|
||||||
navigate('detail', {
|
<span>{playlists.length} Playlists</span>
|
||||||
songId: song.id,
|
<span>{favorites.length} Favoriten</span>
|
||||||
_siblingIds: recentlyPlayed.map((s) => s.id),
|
{/snippet}
|
||||||
_siblingKey: 'songId',
|
|
||||||
})}
|
{#snippet toolbar()}
|
||||||
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
|
<!-- Upload button + file status -->
|
||||||
>
|
<div class="upload-section">
|
||||||
<div
|
<button class="upload-btn" onclick={() => fileInput.click()}>
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
|
<UploadSimple size={14} />
|
||||||
|
<span>Musik hochladen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if uploadFiles.length > 0}
|
||||||
|
<div class="upload-list">
|
||||||
|
{#each uploadFiles as uf, i (uf.file.name + i)}
|
||||||
|
<div
|
||||||
|
class="upload-item"
|
||||||
|
class:success={uf.status === 'success'}
|
||||||
|
class:error={uf.status === 'error'}
|
||||||
|
>
|
||||||
|
<span class="upload-name">{uf.file.name}</span>
|
||||||
|
{#if uf.status === 'uploading'}
|
||||||
|
<SpinnerGap size={12} class="spinner" />
|
||||||
|
{:else if uf.status === 'success'}
|
||||||
|
<Check size={12} />
|
||||||
|
{:else if uf.status === 'error'}
|
||||||
|
<button class="upload-remove" onclick={() => removeUpload(i)} title={uf.error}>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="upload-remove" onclick={() => removeUpload(i)}>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet listHeader()}
|
||||||
|
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet item(song)}
|
||||||
|
<button
|
||||||
|
onclick={() =>
|
||||||
|
navigate('detail', {
|
||||||
|
songId: song.id,
|
||||||
|
_siblingIds: recentlyPlayed.map((s) => s.id),
|
||||||
|
_siblingKey: 'songId',
|
||||||
|
})}
|
||||||
|
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
♫
|
<div
|
||||||
</div>
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
|
||||||
<div class="min-w-0 flex-1">
|
>
|
||||||
<p class="truncate text-sm text-white/80">{song.title}</p>
|
♫
|
||||||
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
|
</div>
|
||||||
</div>
|
<div class="min-w-0 flex-1">
|
||||||
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
|
<p class="truncate text-sm text-white/80">{song.title}</p>
|
||||||
</button>
|
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
|
||||||
{/snippet}
|
</div>
|
||||||
</BaseListView>
|
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</BaseListView>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.music-list-view {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: hsl(var(--color-primary) / 0.12);
|
||||||
|
border: 2px dashed hsl(var(--color-primary));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px dashed hsl(var(--color-border));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.upload-btn:hover {
|
||||||
|
border-color: hsl(var(--color-border-strong));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.upload-item.success {
|
||||||
|
color: hsl(var(--color-success));
|
||||||
|
}
|
||||||
|
.upload-item.error {
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.upload-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.upload-section .spinner) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -149,12 +149,14 @@
|
||||||
{#each filtered as note (note.id)}
|
{#each filtered as note (note.id)}
|
||||||
{#if editingId === note.id}
|
{#if editingId === note.id}
|
||||||
<!-- Inline editor -->
|
<!-- Inline editor -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="note-item editing"
|
class="note-item editing"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Escape') saveEdit();
|
if (e.key === 'Escape') saveEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="ed-title"
|
class="ed-title"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -190,7 +192,12 @@
|
||||||
{#if note.content}
|
{#if note.content}
|
||||||
<p class="note-preview">{getPreview(note.content, 60)}</p>
|
<p class="note-preview">{getPreview(note.content, 60)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="note-meta">{formatRelativeTime(note.updatedAt)}</span>
|
<span class="note-meta">
|
||||||
|
{formatRelativeTime(note.updatedAt)}
|
||||||
|
{#if note.transcriptModel}
|
||||||
|
<span class="stt-chip" title="STT-Pipeline">🎤 {note.transcriptModel}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -324,9 +331,21 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.note-meta {
|
.note-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
}
|
}
|
||||||
|
.stt-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-muted) / 0.6);
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Inline Editor ──────────────────────────── */
|
/* ── Inline Editor ──────────────────────────── */
|
||||||
.note-item.editing {
|
.note-item.editing {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export function toNote(local: LocalNote): Note {
|
||||||
title: local.title,
|
title: local.title,
|
||||||
content: local.content,
|
content: local.content,
|
||||||
color: local.color,
|
color: local.color,
|
||||||
|
transcriptModel: local.transcriptModel ?? null,
|
||||||
isPinned: local.isPinned,
|
isPinned: local.isPinned,
|
||||||
isArchived: local.isArchived,
|
isArchived: local.isArchived,
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { noteTable } from '../collections';
|
||||||
import { toNote } from '../queries';
|
import { toNote } from '../queries';
|
||||||
import type { LocalNote, Note } from '../types';
|
import type { LocalNote, Note } from '../types';
|
||||||
import { encryptRecord } from '$lib/data/crypto';
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
|
import { transcribeAudio } from '$lib/voice/transcribe';
|
||||||
|
|
||||||
export const notesStore = {
|
export const notesStore = {
|
||||||
async createNote(data: { title?: string; content?: string; color?: string | null }) {
|
async createNote(data: { title?: string; content?: string; color?: string | null }) {
|
||||||
|
|
@ -60,32 +61,20 @@ export const notesStore = {
|
||||||
*/
|
*/
|
||||||
async transcribeIntoNote(noteId: string, blob: Blob, language?: string): Promise<void> {
|
async transcribeIntoNote(noteId: string, blob: Blob, language?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const result = await transcribeAudio(blob, language);
|
||||||
const ext = blob.type.includes('webm')
|
const transcript = result.text;
|
||||||
? '.webm'
|
|
||||||
: blob.type.includes('mp4')
|
|
||||||
? '.m4a'
|
|
||||||
: '.audio';
|
|
||||||
form.append('file', blob, `note${ext}`);
|
|
||||||
if (language) form.append('language', language);
|
|
||||||
|
|
||||||
const response = await fetch('/api/v1/voice/transcribe', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(text || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = (await response.json()) as { text: string };
|
|
||||||
const transcript = (result.text ?? '').trim();
|
|
||||||
|
|
||||||
// Use the first line as the title if it's short — keeps the
|
|
||||||
// note browseable without forcing the user to rename it.
|
|
||||||
const firstLine = transcript.split('\n')[0]?.trim() ?? '';
|
const firstLine = transcript.split('\n')[0]?.trim() ?? '';
|
||||||
const title = firstLine.length > 0 && firstLine.length <= 80 ? firstLine : 'Sprachnotiz';
|
const title = firstLine.length > 0 && firstLine.length <= 80 ? firstLine : 'Sprachnotiz';
|
||||||
|
|
||||||
await this.updateNote(noteId, { title, content: transcript });
|
const diff: Partial<LocalNote> = {
|
||||||
|
title,
|
||||||
|
content: transcript,
|
||||||
|
transcriptModel: result.model,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await encryptRecord('notes', diff);
|
||||||
|
await noteTable.update(noteId, diff);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
await this.updateNote(noteId, {
|
await this.updateNote(noteId, {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export interface LocalNote extends BaseRecord {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
/** STT backend/model identifier. Set when note created via voice. */
|
||||||
|
transcriptModel?: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +25,7 @@ export interface Note {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
transcriptModel: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,10 @@
|
||||||
// ─── Quick-add state ──────────────────────────────────────────
|
// ─── Quick-add state ──────────────────────────────────────────
|
||||||
let quickText = $state('');
|
let quickText = $state('');
|
||||||
let quickSaving = $state(false);
|
let quickSaving = $state(false);
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
let cameraInput: HTMLInputElement | undefined = $state();
|
||||||
|
let galleryInput: HTMLInputElement | undefined = $state();
|
||||||
let photoUploading = $state(false);
|
let photoUploading = $state(false);
|
||||||
|
let showPhotoMenu = $state(false);
|
||||||
|
|
||||||
async function submitText() {
|
async function submitText() {
|
||||||
const text = quickText.trim();
|
const text = quickText.trim();
|
||||||
|
|
@ -82,6 +84,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCamera() {
|
||||||
|
showPhotoMenu = false;
|
||||||
|
cameraInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGallery() {
|
||||||
|
showPhotoMenu = false;
|
||||||
|
galleryInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowClick(e: MouseEvent) {
|
||||||
|
if (showPhotoMenu) showPhotoMenu = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function onPhotoSelected(event: Event) {
|
async function onPhotoSelected(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|
@ -111,118 +127,158 @@
|
||||||
});
|
});
|
||||||
const pct =
|
const pct =
|
||||||
analysis.confidence != null ? ` · KI ${Math.round(analysis.confidence * 100)}%` : '';
|
analysis.confidence != null ? ` · KI ${Math.round(analysis.confidence * 100)}%` : '';
|
||||||
toast.success(`📷 Mahlzeit hinzugefügt${pct}`);
|
toast.success(`Mahlzeit hinzugefuegt${pct}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('photo quick add failed:', err);
|
console.error('photo quick add failed:', err);
|
||||||
toast.error('Foto-Analyse fehlgeschlagen');
|
toast.error('Foto-Analyse fehlgeschlagen');
|
||||||
} finally {
|
} finally {
|
||||||
photoUploading = false;
|
photoUploading = false;
|
||||||
if (fileInput) fileInput.value = '';
|
if (cameraInput) cameraInput.value = '';
|
||||||
|
if (galleryInput) galleryInput.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={todayMeals} getKey={(m) => m.id} emptyTitle="Noch keine Mahlzeiten heute">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
{#snippet toolbar()}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- Calorie progress -->
|
<div onclick={handleWindowClick}>
|
||||||
<div class="text-center">
|
<BaseListView items={todayMeals} getKey={(m) => m.id} emptyTitle="Noch keine Mahlzeiten heute">
|
||||||
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
|
{#snippet toolbar()}
|
||||||
<p class="text-xs text-white/40">
|
<!-- Calorie progress -->
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
|
||||||
|
<p class="text-xs text-white/40">
|
||||||
|
{#if goal}
|
||||||
|
von {goal.dailyCalories} kcal
|
||||||
|
{:else}
|
||||||
|
kcal heute
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
{#if goal}
|
{#if goal}
|
||||||
von {goal.dailyCalories} kcal
|
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
|
||||||
{:else}
|
<div
|
||||||
kcal heute
|
class="h-full rounded-full transition-all {calorieProgress >= 100
|
||||||
{/if}
|
? 'bg-green-400'
|
||||||
</p>
|
: 'bg-blue-400'}"
|
||||||
{#if goal}
|
style="width: {calorieProgress}%"
|
||||||
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
|
></div>
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all {calorieProgress >= 100
|
|
||||||
? 'bg-green-400'
|
|
||||||
: 'bg-blue-400'}"
|
|
||||||
style="width: {calorieProgress}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick-add bar -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={quickText}
|
|
||||||
onkeydown={onTextKeydown}
|
|
||||||
placeholder="Was hast du gegessen?"
|
|
||||||
disabled={quickSaving}
|
|
||||||
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => void submitText()}
|
|
||||||
disabled={!quickText.trim() || quickSaving}
|
|
||||||
aria-label="Mahlzeit speichern"
|
|
||||||
title="Speichern"
|
|
||||||
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{quickSaving ? '…' : '↵'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => fileInput?.click()}
|
|
||||||
disabled={photoUploading}
|
|
||||||
aria-label="Foto aufnehmen"
|
|
||||||
title="Foto"
|
|
||||||
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{photoUploading ? '…' : '📷'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
class="hidden"
|
|
||||||
onchange={onPhotoSelected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet header()}
|
|
||||||
<span class="mx-auto">{Math.round(totalProtein)}g Protein · {todayMeals.length} Mahlzeiten</span
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet item(meal)}
|
|
||||||
<a
|
|
||||||
href="/nutriphi/{meal.id}"
|
|
||||||
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs text-white/50"
|
|
||||||
>{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
|
|
||||||
>
|
|
||||||
{#if meal.inputType === 'photo'}
|
|
||||||
<span class="text-xs text-white/40">📷</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate text-sm text-white/70">{meal.description}</p>
|
|
||||||
</div>
|
|
||||||
{#if meal.photoThumbnailUrl || meal.photoUrl}
|
|
||||||
<img
|
|
||||||
src={meal.photoThumbnailUrl ?? meal.photoUrl}
|
|
||||||
alt={meal.description}
|
|
||||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if meal.nutrition}
|
|
||||||
<span class="whitespace-nowrap text-xs text-white/50"
|
|
||||||
>{Math.round(meal.nutrition.calories)} kcal</span
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
{/snippet}
|
<!-- Quick-add bar -->
|
||||||
</BaseListView>
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={quickText}
|
||||||
|
onkeydown={onTextKeydown}
|
||||||
|
placeholder="Was hast du gegessen?"
|
||||||
|
disabled={quickSaving}
|
||||||
|
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => void submitText()}
|
||||||
|
disabled={!quickText.trim() || quickSaving}
|
||||||
|
aria-label="Mahlzeit speichern"
|
||||||
|
title="Speichern"
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{quickSaving ? '…' : '↵'}
|
||||||
|
</button>
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showPhotoMenu = !showPhotoMenu;
|
||||||
|
}}
|
||||||
|
disabled={photoUploading}
|
||||||
|
aria-label="Foto hinzufuegen"
|
||||||
|
title="Foto"
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{photoUploading ? '...' : '📷'}
|
||||||
|
</button>
|
||||||
|
{#if showPhotoMenu}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full right-0 z-10 mb-1 flex flex-col overflow-hidden rounded-lg border border-white/10 bg-[hsl(var(--color-card))] shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openCamera}
|
||||||
|
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-white/80 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span>📸</span> Kamera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openGallery}
|
||||||
|
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-white/80 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span>🖼️</span> Mediathek
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Camera capture (mobile: opens camera directly) -->
|
||||||
|
<input
|
||||||
|
bind:this={cameraInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
class="hidden"
|
||||||
|
onchange={onPhotoSelected}
|
||||||
|
/>
|
||||||
|
<!-- Gallery / file picker (no capture attr → opens gallery / file dialog) -->
|
||||||
|
<input
|
||||||
|
bind:this={galleryInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={onPhotoSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet header()}
|
||||||
|
<span class="mx-auto"
|
||||||
|
>{Math.round(totalProtein)}g Protein · {todayMeals.length} Mahlzeiten</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet item(meal)}
|
||||||
|
<a
|
||||||
|
href="/nutriphi/{meal.id}"
|
||||||
|
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-white/50"
|
||||||
|
>{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
|
||||||
|
>
|
||||||
|
{#if meal.inputType === 'photo'}
|
||||||
|
<span class="text-xs text-white/40">📷</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-sm text-white/70">{meal.description}</p>
|
||||||
|
</div>
|
||||||
|
{#if meal.photoThumbnailUrl || meal.photoUrl}
|
||||||
|
<img
|
||||||
|
src={meal.photoThumbnailUrl ?? meal.photoUrl}
|
||||||
|
alt={meal.description}
|
||||||
|
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if meal.nutrition}
|
||||||
|
<span class="whitespace-nowrap text-xs text-white/50"
|
||||||
|
>{Math.round(meal.nutrition.calories)} kcal</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</BaseListView>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 p-0 sm:p-4"
|
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="lightbox-backdrop" onclick={handleBackdropClick}>
|
<div class="lightbox-backdrop" onclick={handleBackdropClick}>
|
||||||
<div class="lightbox-container">
|
<div class="lightbox-container">
|
||||||
<button class="close-btn" onclick={onClose}>
|
<button class="close-btn" onclick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,32 @@
|
||||||
import { BaseListView } from '@mana/shared-ui';
|
import { BaseListView } from '@mana/shared-ui';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import type { LocalPlant, LocalWateringSchedule } from './types';
|
import type { LocalPlant, LocalWateringSchedule } from './types';
|
||||||
|
import { plantMutations } from './mutations';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let newName = $state('');
|
||||||
|
let newScientific = $state('');
|
||||||
|
|
||||||
|
async function handleCreate(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const plant = await plantMutations.create({
|
||||||
|
name,
|
||||||
|
scientificName: newScientific.trim() || undefined,
|
||||||
|
});
|
||||||
|
newName = '';
|
||||||
|
newScientific = '';
|
||||||
|
creating = false;
|
||||||
|
navigate('detail', {
|
||||||
|
plantId: plant.id,
|
||||||
|
_siblingIds: [...plants.map((p) => p.id), plant.id],
|
||||||
|
_siblingKey: 'plantId',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const plantsQuery = useLiveQueryWithDefault(async () => {
|
const plantsQuery = useLiveQueryWithDefault(async () => {
|
||||||
const all = await db.table<LocalPlant>('plants').toArray();
|
const all = await db.table<LocalPlant>('plants').toArray();
|
||||||
return all.filter((p) => !p.deletedAt && p.isActive);
|
return all.filter((p) => !p.deletedAt && p.isActive);
|
||||||
|
|
@ -47,6 +70,47 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle={$_('planta.list.empty')}>
|
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle={$_('planta.list.empty')}>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-white/50 transition-colors hover:text-white/80"
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
>
|
||||||
|
{creating
|
||||||
|
? $_('planta.create.cancel', { default: 'Abbrechen' })
|
||||||
|
: $_('planta.create.new', { default: '+ Neue Pflanze' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg bg-white/5 p-3" onsubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newName}
|
||||||
|
placeholder={$_('planta.create.namePlaceholder', { default: 'Name (z. B. Monstera)' })}
|
||||||
|
required
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newScientific}
|
||||||
|
placeholder={$_('planta.create.scientificPlaceholder', {
|
||||||
|
default: 'Botanischer Name (optional)',
|
||||||
|
})}
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
>
|
||||||
|
{$_('planta.create.save', { default: 'Pflanze anlegen' })}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<span>{$_('planta.list.count', { values: { count: plants.length } })}</span>
|
<span>{$_('planta.list.count', { values: { count: plants.length } })}</span>
|
||||||
{#if dueForWatering.length > 0}
|
{#if dueForWatering.length > 0}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,34 @@
|
||||||
import { BaseListView } from '@mana/shared-ui';
|
import { BaseListView } from '@mana/shared-ui';
|
||||||
import type { LocalDeck, LocalSlide } from './types';
|
import type { LocalDeck, LocalSlide } from './types';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
|
import { decksStore } from './stores/decks.svelte';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let newTitle = $state('');
|
||||||
|
let newDescription = $state('');
|
||||||
|
|
||||||
|
async function handleCreate(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = newTitle.trim();
|
||||||
|
if (!title) return;
|
||||||
|
const deck = await decksStore.createDeck({
|
||||||
|
title,
|
||||||
|
description: newDescription.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (deck) {
|
||||||
|
newTitle = '';
|
||||||
|
newDescription = '';
|
||||||
|
creating = false;
|
||||||
|
navigate('detail', {
|
||||||
|
deckId: deck.id,
|
||||||
|
_siblingIds: [...decks.map((d) => d.id), deck.id],
|
||||||
|
_siblingKey: 'deckId',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const decksQuery = useLiveQueryWithDefault(async () => {
|
const decksQuery = useLiveQueryWithDefault(async () => {
|
||||||
const all = await db.table<LocalDeck>('presiDecks').toArray();
|
const all = await db.table<LocalDeck>('presiDecks').toArray();
|
||||||
return all.filter((d) => !d.deletedAt);
|
return all.filter((d) => !d.deletedAt);
|
||||||
|
|
@ -30,6 +55,44 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Präsentationen">
|
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Präsentationen">
|
||||||
|
{#snippet toolbar()}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/40">{decks.length} Präsentationen</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-white/50 transition-colors hover:text-white/80"
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
>
|
||||||
|
{creating ? 'Abbrechen' : '+ Neue Präsentation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg bg-white/5 p-3" onsubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newTitle}
|
||||||
|
placeholder="Titel (z. B. Q2 Review)"
|
||||||
|
required
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newDescription}
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!newTitle.trim()}
|
||||||
|
>
|
||||||
|
Präsentation erstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<span>{decks.length} Präsentationen</span>
|
<span>{decks.length} Präsentationen</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,45 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords, encryptRecord } from '$lib/data/crypto';
|
||||||
import { BaseListView } from '@mana/shared-ui';
|
import { BaseListView } from '@mana/shared-ui';
|
||||||
import type { LocalQuestion, LocalCollection } from './types';
|
import type { LocalQuestion, LocalCollection } from './types';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
|
import { questionTable } from './collections';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let newTitle = $state('');
|
||||||
|
let newDescription = $state('');
|
||||||
|
|
||||||
|
async function handleCreate(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = newTitle.trim();
|
||||||
|
if (!title) return;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const newLocal: LocalQuestion = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description: newDescription.trim() || null,
|
||||||
|
collectionId: null,
|
||||||
|
status: 'open',
|
||||||
|
priority: 'normal',
|
||||||
|
tags: [],
|
||||||
|
researchDepth: 'standard',
|
||||||
|
};
|
||||||
|
await encryptRecord('questions', newLocal);
|
||||||
|
await questionTable.add(newLocal);
|
||||||
|
newTitle = '';
|
||||||
|
newDescription = '';
|
||||||
|
creating = false;
|
||||||
|
navigate('detail', {
|
||||||
|
questionId: id,
|
||||||
|
_siblingIds: [...sorted.map((q) => q.id), id],
|
||||||
|
_siblingKey: 'questionId',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const questionsQuery = useLiveQueryWithDefault(async () => {
|
const questionsQuery = useLiveQueryWithDefault(async () => {
|
||||||
const all = await db.table<LocalQuestion>('questions').toArray();
|
const all = await db.table<LocalQuestion>('questions').toArray();
|
||||||
const visible = all.filter((q) => !q.deletedAt);
|
const visible = all.filter((q) => !q.deletedAt);
|
||||||
|
|
@ -48,6 +80,46 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine offenen Fragen">
|
<BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine offenen Fragen">
|
||||||
|
{#snippet toolbar()}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/40"
|
||||||
|
>{questions.length} Fragen · {collections.length} Sammlungen</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-white/50 transition-colors hover:text-white/80"
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
>
|
||||||
|
{creating ? 'Abbrechen' : '+ Neue Frage'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg bg-white/5 p-3" onsubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newTitle}
|
||||||
|
placeholder="Was möchtest du herausfinden?"
|
||||||
|
required
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newDescription}
|
||||||
|
placeholder="Kontext / Details (optional)"
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!newTitle.trim()}
|
||||||
|
>
|
||||||
|
Frage stellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<span>{questions.length} Fragen</span>
|
<span>{questions.length} Fragen</span>
|
||||||
<span>{collections.length} Sammlungen</span>
|
<span>{collections.length} Sammlungen</span>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import type { LocalSkill } from './types';
|
import type { LocalSkill } from './types';
|
||||||
import { LEVEL_NAMES, BRANCH_INFO, xpProgress, type SkillBranch } from './types';
|
import { LEVEL_NAMES, BRANCH_INFO, xpProgress, type SkillBranch } from './types';
|
||||||
|
import { skillStore } from './stores/skills.svelte';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
|
@ -21,9 +22,73 @@
|
||||||
|
|
||||||
const totalXp = $derived(skills.reduce((sum, s) => sum + s.totalXp, 0));
|
const totalXp = $derived(skills.reduce((sum, s) => sum + s.totalXp, 0));
|
||||||
const highestLevel = $derived(Math.max(0, ...skills.map((s) => s.level)));
|
const highestLevel = $derived(Math.max(0, ...skills.map((s) => s.level)));
|
||||||
|
|
||||||
|
const branches = Object.entries(BRANCH_INFO) as [
|
||||||
|
SkillBranch,
|
||||||
|
(typeof BRANCH_INFO)[SkillBranch],
|
||||||
|
][];
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let newName = $state('');
|
||||||
|
let newBranch = $state<SkillBranch>('custom');
|
||||||
|
|
||||||
|
async function handleCreate(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const skill = await skillStore.addSkill({ name, branch: newBranch });
|
||||||
|
newName = '';
|
||||||
|
newBranch = 'custom';
|
||||||
|
creating = false;
|
||||||
|
navigate('detail', {
|
||||||
|
skillId: skill.id,
|
||||||
|
_siblingIds: [...skills.map((s) => s.id), skill.id],
|
||||||
|
_siblingKey: 'skillId',
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={skills} getKey={(s) => s.id} emptyTitle="Keine Skills angelegt">
|
<BaseListView items={skills} getKey={(s) => s.id} emptyTitle="Keine Skills angelegt">
|
||||||
|
{#snippet toolbar()}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/40">{totalXp} XP · Level {highestLevel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-white/50 transition-colors hover:text-white/80"
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
>
|
||||||
|
{creating ? 'Abbrechen' : '+ Neuer Skill'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg bg-white/5 p-3" onsubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newName}
|
||||||
|
placeholder="Skill-Name (z. B. Gitarre, Python, Kochen)"
|
||||||
|
required
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={newBranch}
|
||||||
|
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white focus:border-white/20 focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each branches as [key, info] (key)}
|
||||||
|
<option value={key}>{info.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
>
|
||||||
|
Skill anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<span>{totalXp} XP</span>
|
<span>{totalXp} XP</span>
|
||||||
<span>Level {highestLevel}</span>
|
<span>Level {highestLevel}</span>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
let { result, onClose }: Props = $props();
|
let { result, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const rarity = RARITY_INFO[result.achievement.rarity];
|
const rarity = RARITY_INFO[result.achievement.rarity];
|
||||||
|
|
||||||
function getRarityGradient(r: string): string {
|
function getRarityGradient(r: string): string {
|
||||||
|
|
@ -31,6 +32,10 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
// svelte-ignore a11y_interactive_supports_focus // svelte-ignore a11y_click_events_have_key_events
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
@ -78,6 +80,7 @@
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
<!-- Quick XP Presets -->
|
<!-- Quick XP Presets -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-300"> Schnellauswahl </label>
|
<label class="mb-2 block text-sm font-medium text-gray-300"> Schnellauswahl </label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each xpPresets as preset}
|
{#each xpPresets as preset}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@
|
||||||
|
|
||||||
let { skill, onClose, onSave, onDelete }: Props = $props();
|
let { skill, onClose, onSave, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let name = $state(skill.name);
|
let name = $state(skill.name);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let description = $state(skill.description);
|
let description = $state(skill.description);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let branch = $state<SkillBranch>(skill.branch);
|
let branch = $state<SkillBranch>(skill.branch);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
@ -45,9 +48,13 @@
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
onDelete();
|
onDelete();
|
||||||
onClose();
|
onClose();
|
||||||
|
// svelte-ignore a11y_interactive_supports_focus
|
||||||
|
// svelte-ignore a11y_click_events_have_key_events
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
@ -126,6 +133,7 @@
|
||||||
|
|
||||||
<!-- Branch -->
|
<!-- Branch -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-300"> Kategorie </label>
|
<label class="mb-2 block text-sm font-medium text-gray-300"> Kategorie </label>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
{#each Object.entries(BRANCH_INFO) as [key, info]}
|
{#each Object.entries(BRANCH_INFO) as [key, info]}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
let { skillName, newLevel, onClose }: Props = $props();
|
let { skillName, newLevel, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
|
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
|
||||||
|
|
||||||
// Auto-close after 4 seconds
|
// Auto-close after 4 seconds
|
||||||
|
|
@ -32,6 +33,10 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
// svelte-ignore a11y_interactive_supports_focus // svelte-ignore a11y_click_events_have_key_events
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@
|
||||||
|
|
||||||
<!-- Quick Duration Buttons -->
|
<!-- Quick Duration Buttons -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
||||||
{$_('entry.duration')}
|
{$_('entry.duration')}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -337,6 +338,7 @@
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>Tags</label
|
>Tags</label
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
const allClients = getContext<{ value: Client[] }>('clients');
|
const allClients = getContext<{ value: Client[] }>('clients');
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let editDescription = $state(entry.description);
|
let editDescription = $state(entry.description);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let editProjectId = $state(entry.projectId ?? '');
|
let editProjectId = $state(entry.projectId ?? '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let editIsBillable = $state(entry.isBillable);
|
let editIsBillable = $state(entry.isBillable);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let editDurationMinutes = $state(Math.round(entry.duration / 60));
|
let editDurationMinutes = $state(Math.round(entry.duration / 60));
|
||||||
|
|
||||||
// Sync when entry changes
|
// Sync when entry changes
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
|
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
|
||||||
|
|
||||||
// Animation
|
// Animation
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let animatedOffset = $state(circumference);
|
let animatedOffset = $state(circumference);
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@
|
||||||
{@const x = ((city.lng + 180) / 360) * 800}
|
{@const x = ((city.lng + 180) / 360) * 800}
|
||||||
{@const y = ((90 - city.lat) / 180) * 400}
|
{@const y = ((90 - city.lat) / 180) * 400}
|
||||||
{@const isSelected = selectedTimezones.includes(city.timezone)}
|
{@const isSelected = selectedTimezones.includes(city.timezone)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
|
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
|
||||||
<circle
|
<circle
|
||||||
cx={x}
|
cx={x}
|
||||||
|
|
|
||||||
|
|
@ -204,11 +204,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="task-content">
|
<div class="task-content">
|
||||||
<p class="task-title" class:completed={task.isCompleted}>{task.title}</p>
|
<p class="task-title" class:completed={task.isCompleted}>{task.title}</p>
|
||||||
{#if task.dueDate || taskTags.length > 0}
|
{#if task.dueDate || taskTags.length > 0 || task.transcriptModel}
|
||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
{#if task.dueDate}
|
{#if task.dueDate}
|
||||||
<span class="task-due">{new Date(task.dueDate).toLocaleDateString('de')}</span>
|
<span class="task-due">{new Date(task.dueDate).toLocaleDateString('de')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if task.transcriptModel}
|
||||||
|
<span class="stt-chip" title="STT-Pipeline">🎤 {task.transcriptModel}</span>
|
||||||
|
{/if}
|
||||||
{#each taskTags as tag (tag.id)}
|
{#each taskTags as tag (tag.id)}
|
||||||
<span class="tag-pill" style="--tag-color: {tag.color}">
|
<span class="tag-pill" style="--tag-color: {tag.color}">
|
||||||
<span class="tag-dot" style="background: {tag.color}"></span>
|
<span class="tag-dot" style="background: {tag.color}"></span>
|
||||||
|
|
@ -366,6 +369,17 @@
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
}
|
}
|
||||||
|
.stt-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-muted) / 0.6);
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
.tag-pill {
|
.tag-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const StepIcon = $derived(steps[step].icon);
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (step < steps.length - 1) {
|
if (step < steps.length - 1) {
|
||||||
step++;
|
step++;
|
||||||
|
|
@ -62,7 +64,7 @@
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center p-8 text-center">
|
<div class="flex flex-col items-center p-8 text-center">
|
||||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||||
<svelte:component this={steps[step].icon} size={32} class="text-primary" />
|
<StepIcon size={32} class="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mb-2 text-xl font-bold text-foreground">{steps[step].title}</h2>
|
<h2 class="mb-2 text-xl font-bold text-foreground">{steps[step].title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<div class="space-y-4 p-5">
|
<div class="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
|
<label class="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -130,6 +131,7 @@
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
||||||
>{$_('todo.board.groupBy')}</label
|
>{$_('todo.board.groupBy')}</label
|
||||||
>
|
>
|
||||||
|
|
@ -143,6 +145,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
||||||
>{$_('todo.board.layout')}</label
|
>{$_('todo.board.layout')}</label
|
||||||
>
|
>
|
||||||
|
|
@ -160,6 +163,7 @@
|
||||||
<!-- Columns -->
|
<!-- Columns -->
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="text-xs font-medium text-muted-foreground"
|
<label class="text-xs font-medium text-muted-foreground"
|
||||||
>{$_('todo.board.columns')}</label
|
>{$_('todo.board.columns')}</label
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
{#if isEditing}
|
{#if isEditing}
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
bind:value={editTitle}
|
bind:value={editTitle}
|
||||||
onblur={saveTitle}
|
onblur={saveTitle}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export function toTask(local: LocalTask): Task {
|
||||||
completedAt: local.completedAt,
|
completedAt: local.completedAt,
|
||||||
order: local.order,
|
order: local.order,
|
||||||
subtasks: local.subtasks ?? null,
|
subtasks: local.subtasks ?? null,
|
||||||
|
transcriptModel: local.transcriptModel ?? null,
|
||||||
metadata: local.metadata ?? null,
|
metadata: local.metadata ?? null,
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { toTask } from '../queries';
|
||||||
import type { LocalTask, TaskPriority, Subtask } from '../types';
|
import type { LocalTask, TaskPriority, Subtask } from '../types';
|
||||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||||
|
import { transcribeAudio } from '$lib/voice/transcribe';
|
||||||
import { TodoEvents } from '@mana/shared-utils/analytics';
|
import { TodoEvents } from '@mana/shared-utils/analytics';
|
||||||
import { tagCollection, type LocalTag } from '@mana/shared-stores';
|
import { tagCollection, type LocalTag } from '@mana/shared-stores';
|
||||||
|
|
||||||
|
|
@ -181,26 +182,9 @@ export const tasksStore = {
|
||||||
*/
|
*/
|
||||||
async transcribeAndParseIntoTask(taskId: string, blob: Blob, language?: string): Promise<void> {
|
async transcribeAndParseIntoTask(taskId: string, blob: Blob, language?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Step 1: speech to text
|
// Step 1: speech to text (shared helper)
|
||||||
const form = new FormData();
|
const sttResult = await transcribeAudio(blob, language);
|
||||||
const ext = blob.type.includes('webm')
|
const transcript = sttResult.text;
|
||||||
? '.webm'
|
|
||||||
: blob.type.includes('mp4')
|
|
||||||
? '.m4a'
|
|
||||||
: '.audio';
|
|
||||||
form.append('file', blob, `task${ext}`);
|
|
||||||
if (language) form.append('language', language);
|
|
||||||
|
|
||||||
const sttResponse = await fetch('/api/v1/voice/transcribe', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
if (!sttResponse.ok) {
|
|
||||||
const text = await sttResponse.text();
|
|
||||||
throw new Error(text || `HTTP ${sttResponse.status}`);
|
|
||||||
}
|
|
||||||
const sttResult = (await sttResponse.json()) as { text: string };
|
|
||||||
const transcript = (sttResult.text ?? '').trim();
|
|
||||||
if (!transcript) {
|
if (!transcript) {
|
||||||
await this.updateTask(taskId, { title: 'Sprachaufgabe' });
|
await this.updateTask(taskId, { title: 'Sprachaufgabe' });
|
||||||
return;
|
return;
|
||||||
|
|
@ -213,7 +197,10 @@ export const tasksStore = {
|
||||||
const parsed = await this.parseTaskText(transcript, language);
|
const parsed = await this.parseTaskText(transcript, language);
|
||||||
const matchedLabelIds = await matchLabelsToTagIds(parsed.labels);
|
const matchedLabelIds = await matchLabelsToTagIds(parsed.labels);
|
||||||
|
|
||||||
const update: Record<string, unknown> = { title: parsed.title };
|
const update: Record<string, unknown> = {
|
||||||
|
title: parsed.title,
|
||||||
|
transcriptModel: sttResult.model,
|
||||||
|
};
|
||||||
if (parsed.dueDate) update.dueDate = parsed.dueDate;
|
if (parsed.dueDate) update.dueDate = parsed.dueDate;
|
||||||
if (parsed.priority) update.priority = parsed.priority;
|
if (parsed.priority) update.priority = parsed.priority;
|
||||||
await this.updateTask(taskId, update);
|
await this.updateTask(taskId, update);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export interface LocalTask extends BaseRecord {
|
||||||
order: number;
|
order: number;
|
||||||
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
|
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
|
||||||
subtasks?: Subtask[] | null;
|
subtasks?: Subtask[] | null;
|
||||||
|
/** STT backend/model identifier (e.g. "whisperx-large-v3"). Set when task created via voice. */
|
||||||
|
transcriptModel?: string | null;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +117,7 @@ export interface Task {
|
||||||
order: number;
|
order: number;
|
||||||
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
|
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
|
||||||
subtasks?: Subtask[] | null;
|
subtasks?: Subtask[] | null;
|
||||||
|
transcriptModel: string | null;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
<!--
|
<!--
|
||||||
uLoad — Workbench ListView
|
uLoad — Workbench ListView
|
||||||
Short links list with click counts.
|
Short links list with click counts and quick link creation.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
import { BaseListView } from '@mana/shared-ui';
|
import { BaseListView } from '@mana/shared-ui';
|
||||||
|
import { Plus, Link as LinkIcon } from '@mana/shared-icons';
|
||||||
import type { LocalLink, LocalFolder } from './types';
|
import type { LocalLink, LocalFolder } from './types';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
|
import { linkTable } from './collections';
|
||||||
|
import { generateShortCode } from './queries';
|
||||||
|
|
||||||
let { navigate }: ViewProps = $props();
|
let { navigate }: ViewProps = $props();
|
||||||
|
|
||||||
|
|
@ -39,6 +43,56 @@
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Quick-add link ──────────────────────────────────────
|
||||||
|
let showAdd = $state(false);
|
||||||
|
let newUrl = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function addLink() {
|
||||||
|
const url = newUrl.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// Auto-prepend https:// if missing
|
||||||
|
const fullUrl = /^https?:\/\//.test(url) ? url : `https://${url}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(fullUrl);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
error = 'Ungültige URL';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Ungültige URL';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortCode = generateShortCode();
|
||||||
|
|
||||||
|
const newRow: LocalLink = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
shortCode,
|
||||||
|
customCode: null,
|
||||||
|
originalUrl: fullUrl,
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
isActive: true,
|
||||||
|
password: null,
|
||||||
|
maxClicks: null,
|
||||||
|
expiresAt: null,
|
||||||
|
clickCount: 0,
|
||||||
|
qrCodeUrl: null,
|
||||||
|
utmSource: null,
|
||||||
|
utmMedium: null,
|
||||||
|
utmCampaign: null,
|
||||||
|
folderId: null,
|
||||||
|
order: links.length,
|
||||||
|
};
|
||||||
|
await encryptRecord('links', newRow);
|
||||||
|
await linkTable.add(newRow);
|
||||||
|
newUrl = '';
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseListView items={sorted} getKey={(l) => l.id} emptyTitle="Keine Links">
|
<BaseListView items={sorted} getKey={(l) => l.id} emptyTitle="Keine Links">
|
||||||
|
|
@ -48,6 +102,33 @@
|
||||||
<span>{folders.length} Ordner</span>
|
<span>{folders.length} Ordner</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet listHeader()}
|
||||||
|
{#if showAdd}
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addLink();
|
||||||
|
}}
|
||||||
|
class="quick-add"
|
||||||
|
>
|
||||||
|
<LinkIcon size={14} class="icon" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input class="add-input" bind:value={newUrl} placeholder="URL einfügen..." autofocus />
|
||||||
|
<button type="submit" class="submit-btn" disabled={!newUrl.trim()}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if error}
|
||||||
|
<p class="error-msg">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<button class="add-toggle" onclick={() => (showAdd = true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Neuer Link</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet item(link)}
|
{#snippet item(link)}
|
||||||
<button
|
<button
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
|
|
@ -71,3 +152,85 @@
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</BaseListView>
|
</BaseListView>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.quick-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add :global(.icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
}
|
||||||
|
.add-input::placeholder {
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
border: 1px dashed hsl(var(--color-border));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.add-toggle:hover {
|
||||||
|
border-color: hsl(var(--color-border-strong));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
margin: -0.25rem 0 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,7 @@
|
||||||
<p class="mb-3 text-xs text-white/50">
|
<p class="mb-3 text-xs text-white/50">
|
||||||
Wenn die KI deine Vermutung nicht erkannt hat, kannst du den Namen hier direkt eintragen.
|
Wenn die KI deine Vermutung nicht erkannt hat, kannst du den Namen hier direkt eintragen.
|
||||||
</p>
|
</p>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={guessText}
|
bind:value={guessText}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="group relative flex w-1.5 shrink-0 cursor-col-resize items-center justify-center
|
class="group relative flex w-1.5 shrink-0 cursor-col-resize items-center justify-center
|
||||||
hover:bg-white/10 {isDragging ? 'bg-white/15' : 'bg-white/5'}"
|
hover:bg-white/10 {isDragging ? 'bg-white/15' : 'bg-white/5'}"
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,7 @@
|
||||||
|
|
||||||
<!-- Scopes -->
|
<!-- Scopes -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="block text-sm font-medium mb-2">Scopes</label>
|
<label class="block text-sm font-medium mb-2">Scopes</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@
|
||||||
{#if showCreateForm}
|
{#if showCreateForm}
|
||||||
<div class="modal-backdrop" role="presentation">
|
<div class="modal-backdrop" role="presentation">
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="modal-backdrop-inner"
|
class="modal-backdrop-inner"
|
||||||
onclick={(e) => e.target === e.currentTarget && (showCreateForm = false)}
|
onclick={(e) => e.target === e.currentTarget && (showCreateForm = false)}
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-foreground">Farbe</label>
|
<label class="mb-2 block text-sm font-medium text-foreground">Farbe</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
{#each PRESET_COLORS as color}
|
{#each PRESET_COLORS as color}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@
|
||||||
let newCardBack = $state('');
|
let newCardBack = $state('');
|
||||||
|
|
||||||
// Live queries for this deck's data
|
// Live queries for this deck's data
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const currentDeck = useDeck(deckId);
|
const currentDeck = useDeck(deckId);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const deckCards = useCardsByDeck(deckId);
|
const deckCards = useCardsByDeck(deckId);
|
||||||
|
|
||||||
// Reactively read values
|
// Reactively read values
|
||||||
|
|
@ -149,6 +151,7 @@
|
||||||
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
|
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
|
||||||
Vorderseite
|
Vorderseite
|
||||||
</label>
|
</label>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
id="card-front"
|
id="card-front"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -271,8 +271,10 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-sm font-medium">Farbe</label>
|
<label class="mb-1 block text-sm font-medium">Farbe</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
{#each COLORS as color}
|
{#each COLORS as color}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@
|
||||||
{#if contactModalStore.isOpen}
|
{#if contactModalStore.isOpen}
|
||||||
{@const isEditing = !!contactModalStore.editContactId}
|
{@const isEditing = !!contactModalStore.editContactId}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
onclick={(e) => e.target === e.currentTarget && contactModalStore.close()}
|
onclick={(e) => e.target === e.currentTarget && contactModalStore.close()}
|
||||||
|
|
|
||||||
|
|
@ -242,10 +242,14 @@
|
||||||
|
|
||||||
<!-- Delete Confirmation -->
|
<!-- Delete Confirmation -->
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
onclick={() => (deleteTarget = null)}
|
onclick={() => (deleteTarget = null)}
|
||||||
|
// svelte-ignore a11y_click_events_have_key_events
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -206,10 +206,14 @@
|
||||||
|
|
||||||
<!-- Delete Confirmation -->
|
<!-- Delete Confirmation -->
|
||||||
{#if showDeleteConfirm}
|
{#if showDeleteConfirm}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
onclick={() => (showDeleteConfirm = false)}
|
onclick={() => (showDeleteConfirm = false)}
|
||||||
|
// svelte-ignore a11y_click_events_have_key_events
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -219,10 +219,14 @@
|
||||||
|
|
||||||
<!-- Delete Confirmation -->
|
<!-- Delete Confirmation -->
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
onclick={() => (deleteTarget = null)}
|
onclick={() => (deleteTarget = null)}
|
||||||
|
// svelte-ignore a11y_click_events_have_key_events
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="amount-row">
|
<div class="amount-row">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="amount-input"
|
class="amount-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,6 @@
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-section {
|
|
||||||
/* main content */
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-section {
|
.timeline-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
||||||
{field.name}{field.required ? ' *' : ''}
|
{field.name}{field.required ? ' *' : ''}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>Status</label
|
>Status</label
|
||||||
>
|
>
|
||||||
|
|
@ -180,6 +181,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>Menge</label
|
>Menge</label
|
||||||
>
|
>
|
||||||
|
|
@ -187,6 +189,7 @@
|
||||||
</div>
|
</div>
|
||||||
{#if locationsCtx.value.length > 0}
|
{#if locationsCtx.value.length > 0}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>Standort</label
|
>Standort</label
|
||||||
>
|
>
|
||||||
|
|
@ -200,6 +203,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if categoriesCtx.value.length > 0}
|
{#if categoriesCtx.value.length > 0}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>Kategorie</label
|
>Kategorie</label
|
||||||
>
|
>
|
||||||
|
|
@ -219,6 +223,7 @@
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||||
>{field.name}</label
|
>{field.name}</label
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
size={20}
|
size={20}
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
|
||||||
/>
|
/>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,10 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-sm font-medium">Farbe</label>
|
<label class="mb-1 block text-sm font-medium">Farbe</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
{#each COLORS as color}
|
{#each COLORS as color}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
<div class="mb-6 rounded-xl border border-border bg-card p-6">
|
<div class="mb-6 rounded-xl border border-border bg-card p-6">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
|
<label class="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -92,6 +93,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-sm font-medium text-muted-foreground">Animation</label>
|
<label class="mb-1 block text-sm font-medium text-muted-foreground">Animation</label>
|
||||||
<select
|
<select
|
||||||
bind:value={newAnimation}
|
bind:value={newAnimation}
|
||||||
|
|
@ -105,6 +107,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-1 block text-sm font-medium text-muted-foreground">Farben</label>
|
<label class="mb-1 block text-sm font-medium text-muted-foreground">Farben</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{#each newColors as color, i}
|
{#each newColors as color, i}
|
||||||
|
|
@ -157,9 +160,11 @@
|
||||||
<div class="mt-3 flex gap-1">
|
<div class="mt-3 flex gap-1">
|
||||||
{#each mood.colors as color}
|
{#each mood.colors as color}
|
||||||
<div class="h-4 w-4 rounded-full" style="background: {color}"></div>
|
<div class="h-4 w-4 rounded-full" style="background: {color}"></div>
|
||||||
|
// svelte-ignore node_invalid_placement_ssr
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if !mood.isDefault}
|
{#if !mood.isDefault}
|
||||||
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<button
|
<button
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
<!-- Create Form -->
|
<!-- Create Form -->
|
||||||
{#if showCreate}
|
{#if showCreate}
|
||||||
<form class="create-form" onsubmit={handleCreate}>
|
<form class="create-form" onsubmit={handleCreate}>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
class="create-title"
|
class="create-title"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
<div class="create-footer">
|
<div class="create-footer">
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
{#each NOTE_COLORS as c}
|
{#each NOTE_COLORS as c}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-dot"
|
class="color-dot"
|
||||||
|
|
@ -372,6 +374,7 @@
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
<div class="note-detail">
|
<div class="note-detail">
|
||||||
{#if note}
|
{#if note}
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button class="back-btn" onclick={handleBack}>
|
<button class="back-btn" onclick={handleBack}>
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
|
|
@ -127,6 +128,7 @@
|
||||||
<div class="detail-footer">
|
<div class="detail-footer">
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
{#each NOTE_COLORS as c}
|
{#each NOTE_COLORS as c}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-dot"
|
class="color-dot"
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@
|
||||||
<!-- Edit form -->
|
<!-- Edit form -->
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
||||||
Mahlzeittyp
|
Mahlzeittyp
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,7 @@
|
||||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 space-y-5">
|
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 space-y-5">
|
||||||
<!-- Meal Type -->
|
<!-- Meal Type -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
||||||
Mahlzeittyp
|
Mahlzeittyp
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 px-4">
|
<div class="flex items-center gap-2 px-4">
|
||||||
{#each currentSlides as _, index}
|
{#each currentSlides as _, index}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
onclick={() => goToSlide(index)}
|
onclick={() => goToSlide(index)}
|
||||||
class="w-2 h-2 rounded-full transition-all"
|
class="w-2 h-2 rounded-full transition-all"
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,13 @@
|
||||||
|
|
||||||
<!-- Research Depth -->
|
<!-- Research Depth -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
|
||||||
Recherchetiefe
|
Recherchetiefe
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
{#each depthOptions as option}
|
{#each depthOptions as option}
|
||||||
|
{@const OptionIcon = option.icon}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (researchDepth = option.value)}
|
onclick={() => (researchDepth = option.value)}
|
||||||
|
|
@ -215,7 +217,7 @@
|
||||||
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.05)]'
|
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.05)]'
|
||||||
: 'border-[hsl(var(--border))] hover:border-[hsl(var(--primary)/0.3)]'}"
|
: 'border-[hsl(var(--border))] hover:border-[hsl(var(--primary)/0.3)]'}"
|
||||||
>
|
>
|
||||||
<svelte:component this={option.icon} class="mb-2 h-5 w-5 text-[hsl(var(--primary))]" />
|
<OptionIcon class="mb-2 h-5 w-5 text-[hsl(var(--primary))]" />
|
||||||
<div class="font-medium text-[hsl(var(--foreground))]">{option.label}</div>
|
<div class="font-medium text-[hsl(var(--foreground))]">{option.label}</div>
|
||||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
{option.description}
|
{option.description}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@
|
||||||
{#if showNewFolderInput}
|
{#if showNewFolderInput}
|
||||||
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
|
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
|
||||||
<FolderPlus size={20} class="text-primary" />
|
<FolderPlus size={20} class="text-primary" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newFolderName}
|
bind:value={newFolderName}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@
|
||||||
{#if showNewFolderInput}
|
{#if showNewFolderInput}
|
||||||
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
|
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
|
||||||
<FolderPlus size={20} class="text-primary" />
|
<FolderPlus size={20} class="text-primary" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newFolderName}
|
bind:value={newFolderName}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="mb-6 flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
<div class="mb-6 flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
||||||
<MagnifyingGlass size={20} class="text-muted-foreground" />
|
<MagnifyingGlass size={20} class="text-muted-foreground" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,9 @@
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
{#each typeConfig as cfg}
|
{#each typeConfig as cfg}
|
||||||
{@const active = visibleTypes.has(cfg.type)}
|
{@const active = visibleTypes.has(cfg.type)}
|
||||||
|
{@const Icon = cfg.icon}
|
||||||
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
|
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
|
||||||
<svelte:component this={cfg.icon} size={14} />
|
<Icon size={14} />
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -180,13 +181,11 @@
|
||||||
<div class="content-col">
|
<div class="content-col">
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
{#if habitIcon}
|
{#if habitIcon}
|
||||||
<svelte:component
|
{@const HabitIcon = habitIcon}
|
||||||
this={habitIcon}
|
<HabitIcon size={16} style="color: {block.color || '#6b7280'}" />
|
||||||
size={16}
|
|
||||||
style="color: {block.color || '#6b7280'}"
|
|
||||||
/>
|
|
||||||
{:else if typeCfg}
|
{:else if typeCfg}
|
||||||
<svelte:component this={typeCfg.icon} size={16} class="item-type-icon" />
|
{@const TypeIcon = typeCfg.icon}
|
||||||
|
<TypeIcon size={16} class="item-type-icon" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="item-title">{block.title}</span>
|
<span class="item-title">{block.title}</span>
|
||||||
{#if block.linkedBlockId}
|
{#if block.linkedBlockId}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each PROJECT_COLORS as color}
|
{#each PROJECT_COLORS as color}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (newColor = color)}
|
onclick={() => (newColor = color)}
|
||||||
|
|
@ -253,6 +254,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each PROJECT_COLORS as color}
|
{#each PROJECT_COLORS as color}
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue