mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat: add shared Phosphor IconPicker, migrate habits from emoji to icons, add photos upload
- Add curated icon registry (73 Phosphor icons, 8 categories) in shared-icons - Add DynamicIcon atom and IconPicker molecule in shared-ui - Migrate habits module from emoji strings to Phosphor icon names - Add Dexie version(2) migration for emoji→icon field rename - Replace inline SVGs in habits with Phosphor components - Add drag-and-drop photo upload to Photos workbench ListView - Add blob: to CSP img-src for upload previews - Add dev:media script and include mana-media in dev:manacore:servers - Add ./toast export to shared-ui package.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ebfc2facaa
commit
8218037841
24 changed files with 1489 additions and 721 deletions
|
|
@ -1,34 +1,21 @@
|
|||
<!--
|
||||
Icon Component — Thin wrapper around DynamicIcon from shared-ui.
|
||||
Renders a Phosphor icon by string name from the curated registry.
|
||||
|
||||
Usage:
|
||||
<Icon name="coffee" size={24} class="text-blue-500" />
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Icon Component - Re-exports from @manacore/shared-icons
|
||||
* This wrapper ensures backward compatibility with existing imports
|
||||
*/
|
||||
import { iconPaths } from '@manacore/shared-icons';
|
||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||
|
||||
interface Props {
|
||||
name: keyof typeof iconPaths;
|
||||
name: string;
|
||||
size?: number;
|
||||
class?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||
|
||||
const path = $derived(iconPaths[name]);
|
||||
</script>
|
||||
|
||||
{#if path}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color || 'currentColor'}
|
||||
viewBox="0 0 256 256"
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html path}
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||
{/if}
|
||||
<DynamicIcon {name} {size} class={className} {color} />
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const HABITS_GUEST_SEED = {
|
|||
{
|
||||
id: 'habit-coffee',
|
||||
title: 'Kaffee',
|
||||
emoji: '\u2615',
|
||||
icon: 'coffee',
|
||||
color: '#f59e0b',
|
||||
targetPerDay: 3,
|
||||
order: 0,
|
||||
|
|
@ -29,7 +29,7 @@ export const HABITS_GUEST_SEED = {
|
|||
{
|
||||
id: 'habit-water',
|
||||
title: 'Wasser',
|
||||
emoji: '\ud83d\udca7',
|
||||
icon: 'drop',
|
||||
color: '#06b6d4',
|
||||
targetPerDay: 8,
|
||||
order: 1,
|
||||
|
|
@ -38,7 +38,7 @@ export const HABITS_GUEST_SEED = {
|
|||
{
|
||||
id: 'habit-workout',
|
||||
title: 'Workout',
|
||||
emoji: '\ud83d\udcaa',
|
||||
icon: 'barbell',
|
||||
color: '#22c55e',
|
||||
targetPerDay: 1,
|
||||
order: 2,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<script lang="ts">
|
||||
import type { Habit, HabitLog } from '../types';
|
||||
import { formatTime } from '../queries';
|
||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||
|
||||
let {
|
||||
logs,
|
||||
|
|
@ -45,7 +46,9 @@
|
|||
{#if habit}
|
||||
<div class="timeline-entry">
|
||||
<div class="entry-dot" style:background={habit.color}></div>
|
||||
<span class="entry-emoji">{habit.emoji}</span>
|
||||
<span class="entry-icon" style:color={habit.color}>
|
||||
<DynamicIcon name={habit.icon} size={16} weight="regular" />
|
||||
</span>
|
||||
<span class="entry-title">{habit.title}</span>
|
||||
<span class="entry-time">{formatTime(log.timestamp)}</span>
|
||||
{#if log.note}
|
||||
|
|
@ -97,8 +100,9 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-emoji {
|
||||
font-size: 1rem;
|
||||
.entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
import { habitsStore } from '../stores/habits.svelte';
|
||||
import { getCountForDate, getStreak, groupLogsByDate, todayStr, formatTime } from '../queries';
|
||||
import HabitForm from './HabitForm.svelte';
|
||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||
import { CaretLeft, PencilSimple, X } from '@manacore/shared-icons';
|
||||
|
||||
let {
|
||||
habit,
|
||||
|
|
@ -83,33 +85,16 @@
|
|||
<!-- Header -->
|
||||
<header class="detail-header">
|
||||
<button class="back-btn" onclick={onBack}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
<CaretLeft size={20} />
|
||||
</button>
|
||||
<div class="header-info" style:--habit-color={habit.color}>
|
||||
<span class="header-emoji">{habit.emoji}</span>
|
||||
<span class="header-icon">
|
||||
<DynamicIcon name={habit.icon} size={24} weight="bold" />
|
||||
</span>
|
||||
<h2 class="header-title">{habit.title}</h2>
|
||||
</div>
|
||||
<button class="edit-btn" onclick={() => (showEdit = !showEdit)}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<PencilSimple size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
|
@ -160,7 +145,8 @@
|
|||
style:background={habit.color}
|
||||
onclick={() => habitsStore.logHabit(habit.id)}
|
||||
>
|
||||
{habit.emoji} Jetzt loggen
|
||||
<DynamicIcon name={habit.icon} size={18} weight="bold" class="text-white" />
|
||||
Jetzt loggen
|
||||
</button>
|
||||
|
||||
<!-- Timeline -->
|
||||
|
|
@ -176,17 +162,7 @@
|
|||
<span class="log-note">{log.note}</span>
|
||||
{/if}
|
||||
<button class="log-delete" onclick={() => handleDeleteLog(log.id)} title="Entfernen">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -250,8 +226,9 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-emoji {
|
||||
font-size: 1.5rem;
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
|
|
@ -357,6 +334,10 @@
|
|||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.15s;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
-->
|
||||
<script lang="ts">
|
||||
import { habitsStore } from '../stores/habits.svelte';
|
||||
import { HABIT_COLORS, HABIT_EMOJIS, type Habit } from '../types';
|
||||
import { HABIT_COLORS, type Habit } from '../types';
|
||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||
import { IconPicker } from '@manacore/shared-ui/molecules';
|
||||
|
||||
let {
|
||||
habit = null,
|
||||
|
|
@ -17,10 +19,10 @@
|
|||
} = $props();
|
||||
|
||||
let title = $state(habit?.title ?? '');
|
||||
let emoji = $state(habit?.emoji ?? '\u2b50');
|
||||
let icon = $state(habit?.icon ?? 'star');
|
||||
let color = $state(habit?.color ?? '#6366f1');
|
||||
let targetPerDay = $state<string>(habit?.targetPerDay?.toString() ?? '');
|
||||
let showEmojiPicker = $state(false);
|
||||
let showIconPicker = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
|
@ -31,14 +33,14 @@
|
|||
if (habit) {
|
||||
await habitsStore.updateHabit(habit.id, {
|
||||
title: title.trim(),
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
targetPerDay: target,
|
||||
});
|
||||
} else {
|
||||
await habitsStore.createHabit({
|
||||
title: title.trim(),
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
targetPerDay: target,
|
||||
});
|
||||
|
|
@ -62,11 +64,11 @@
|
|||
<div class="form-row">
|
||||
<button
|
||||
type="button"
|
||||
class="emoji-btn"
|
||||
class="icon-btn"
|
||||
style:background={color}
|
||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||
onclick={() => (showIconPicker = !showIconPicker)}
|
||||
>
|
||||
{emoji}
|
||||
<DynamicIcon name={icon} size={20} weight="bold" class="text-white" />
|
||||
</button>
|
||||
<input
|
||||
class="title-input"
|
||||
|
|
@ -77,21 +79,16 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
{#if showEmojiPicker}
|
||||
<div class="emoji-picker">
|
||||
{#each HABIT_EMOJIS as e}
|
||||
<button
|
||||
type="button"
|
||||
class="emoji-option"
|
||||
class:selected={emoji === e}
|
||||
onclick={() => {
|
||||
emoji = e;
|
||||
showEmojiPicker = false;
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
{/each}
|
||||
{#if showIconPicker}
|
||||
<div class="icon-picker-wrapper">
|
||||
<IconPicker
|
||||
selectedIcon={icon}
|
||||
onIconChange={(i) => {
|
||||
icon = i;
|
||||
showIconPicker = false;
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -146,20 +143,19 @@
|
|||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
.icon-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.emoji-btn:hover {
|
||||
.icon-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
|
@ -180,31 +176,13 @@
|
|||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.emoji-option {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
.icon-picker-wrapper {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.125rem;
|
||||
background: transparent;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.emoji-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.emoji-option.selected {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<!--
|
||||
HabitTile — single tappable tile in the tally board.
|
||||
Shows emoji, title, today's count, and optional target.
|
||||
Shows icon, title, today's count, and optional target.
|
||||
Tap = log habit. Long press = undo last.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Habit } from '../types';
|
||||
import { habitsStore } from '../stores/habits.svelte';
|
||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||
import { CaretRight } from '@manacore/shared-icons';
|
||||
|
||||
let {
|
||||
habit,
|
||||
|
|
@ -74,23 +76,16 @@
|
|||
{#if habit.targetPerDay}
|
||||
<div class="progress-ring" style:--progress="{progressPercent}%"></div>
|
||||
{/if}
|
||||
<span class="emoji">{habit.emoji}</span>
|
||||
<span class="icon">
|
||||
<DynamicIcon name={habit.icon} size={32} weight="bold" class="text-white" />
|
||||
</span>
|
||||
<span class="title">{habit.title}</span>
|
||||
<span class="count">
|
||||
{todayCount}{#if habit.targetPerDay}<span class="target">/{habit.targetPerDay}</span>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button class="detail-btn" onclick={() => onDetail(habit)} title="Details">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
<CaretRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -156,8 +151,10 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 2rem;
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
|
|
|||
|
|
@ -26,5 +26,5 @@ export {
|
|||
export { habitTable, habitLogTable, HABITS_GUEST_SEED } from './collections';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export { HABIT_COLORS, HABIT_EMOJIS } from './types';
|
||||
export { HABIT_COLORS, HABIT_ICONS, EMOJI_TO_ICON_MAP } from './types';
|
||||
export type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
|
||||
import { EMOJI_TO_ICON_MAP } from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ export function toHabit(local: LocalHabit): Habit {
|
|||
return {
|
||||
id: local.id,
|
||||
title: local.title,
|
||||
emoji: local.emoji,
|
||||
icon: local.icon ?? EMOJI_TO_ICON_MAP[(local as Record<string, string>).emoji] ?? 'star',
|
||||
color: local.color,
|
||||
targetPerDay: local.targetPerDay,
|
||||
order: local.order,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { LocalHabit, LocalHabitLog } from '../types';
|
|||
export const habitsStore = {
|
||||
async createHabit(data: {
|
||||
title: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
targetPerDay?: number | null;
|
||||
}) {
|
||||
|
|
@ -22,7 +22,7 @@ export const habitsStore = {
|
|||
const newLocal: LocalHabit = {
|
||||
id: crypto.randomUUID(),
|
||||
title: data.title,
|
||||
emoji: data.emoji,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
targetPerDay: data.targetPerDay ?? null,
|
||||
order: count,
|
||||
|
|
@ -36,7 +36,7 @@ export const habitsStore = {
|
|||
async updateHabit(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<LocalHabit, 'title' | 'emoji' | 'color' | 'targetPerDay' | 'isArchived' | 'order'>
|
||||
Pick<LocalHabit, 'title' | 'icon' | 'color' | 'targetPerDay' | 'isArchived' | 'order'>
|
||||
>
|
||||
) {
|
||||
await habitTable.update(id, {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { BaseRecord } from '@manacore/local-store';
|
|||
|
||||
export interface LocalHabit extends BaseRecord {
|
||||
title: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
targetPerDay: number | null;
|
||||
order: number;
|
||||
|
|
@ -29,7 +29,7 @@ export interface LocalHabitLog extends BaseRecord {
|
|||
export interface Habit {
|
||||
id: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
targetPerDay: number | null;
|
||||
order: number;
|
||||
|
|
@ -65,21 +65,51 @@ export const HABIT_COLORS: string[] = [
|
|||
'#f43f5e',
|
||||
];
|
||||
|
||||
export const HABIT_EMOJIS: string[] = [
|
||||
'\u2615', // coffee
|
||||
'\ud83d\udeb6', // cigarette / walking
|
||||
'\ud83c\udfc3', // running
|
||||
'\ud83e\uddd8', // meditation
|
||||
'\ud83d\udca7', // water
|
||||
'\ud83c\udf4e', // apple / healthy food
|
||||
'\ud83d\udcda', // reading
|
||||
'\ud83d\udcaa', // workout
|
||||
'\ud83d\udecc', // sleep
|
||||
'\ud83c\udfb5', // music
|
||||
'\ud83d\udc8a', // pill / medicine
|
||||
'\ud83c\udf7a', // beer
|
||||
'\ud83c\udf55', // pizza / junk food
|
||||
'\ud83d\udeb4', // cycling
|
||||
'\ud83d\udcdd', // journal
|
||||
'\ud83e\uddfc', // teeth / hygiene
|
||||
export const HABIT_ICONS: string[] = [
|
||||
'coffee',
|
||||
'drop',
|
||||
'barbell',
|
||||
'person-simple-run',
|
||||
'person-simple-walk',
|
||||
'person-simple-tai-chi',
|
||||
'bicycle',
|
||||
'book-open',
|
||||
'pencil-simple',
|
||||
'pill',
|
||||
'beer-stein',
|
||||
'pizza',
|
||||
'apple-logo',
|
||||
'music-note',
|
||||
'bed',
|
||||
'tooth',
|
||||
'shower',
|
||||
'cigarette',
|
||||
'heart',
|
||||
'brain',
|
||||
'star',
|
||||
'moon',
|
||||
'target',
|
||||
'fire',
|
||||
];
|
||||
|
||||
/** Maps legacy emoji values to icon names for data migration. */
|
||||
export const EMOJI_TO_ICON_MAP: Record<string, string> = {
|
||||
'\u2615': 'coffee',
|
||||
'\ud83d\udeb6': 'person-simple-walk',
|
||||
'\ud83c\udfc3': 'person-simple-run',
|
||||
'\ud83e\uddd8': 'person-simple-tai-chi',
|
||||
'\ud83d\udca7': 'drop',
|
||||
'\ud83c\udf4e': 'apple-logo',
|
||||
'\ud83d\udcda': 'book-open',
|
||||
'\ud83d\udcaa': 'barbell',
|
||||
'\ud83d\udecc': 'bed',
|
||||
'\ud83c\udfb5': 'music-note',
|
||||
'\ud83d\udc8a': 'pill',
|
||||
'\ud83c\udf7a': 'beer-stein',
|
||||
'\ud83c\udf55': 'pizza',
|
||||
'\ud83d\udeb4': 'bicycle',
|
||||
'\ud83d\udcdd': 'pencil-simple',
|
||||
'\ud83e\uddfc': 'tooth',
|
||||
'\u2b50': 'star',
|
||||
'\ud83d\ude2e\u200d\ud83d\udca8': 'wind',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
<!--
|
||||
Photos — Workbench ListView
|
||||
Photo albums and recent uploads.
|
||||
Drop-to-upload zone, recent photos, albums overview.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalAlbum, LocalFavorite } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { UploadSimple, X, Check, ImageSquare } from '@manacore/shared-icons';
|
||||
|
||||
let { navigate }: ViewProps = $props();
|
||||
|
||||
const MEDIA_URL = import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
|
||||
let albums = $state<LocalAlbum[]>([]);
|
||||
let favorites = $state<LocalFavorite[]>([]);
|
||||
|
|
@ -33,32 +39,405 @@
|
|||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// ─── Upload State ────────────────────────────────────────
|
||||
let dragActive = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
interface UploadFile {
|
||||
file: File;
|
||||
preview: string;
|
||||
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 imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newFiles: UploadFile[] = imageFiles.map((file) => ({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
status: 'pending',
|
||||
}));
|
||||
|
||||
uploadFiles = [...uploadFiles, ...newFiles];
|
||||
uploadAll();
|
||||
}
|
||||
|
||||
async function uploadAll() {
|
||||
if (uploading) return;
|
||||
uploading = true;
|
||||
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
if (uploadFiles[i].status !== 'pending') continue;
|
||||
|
||||
uploadFiles[i].status = 'uploading';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFiles[i].file);
|
||||
formData.append('app', 'photos');
|
||||
|
||||
const response = await fetch(`${MEDIA_URL}/api/v1/media/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
uploadFiles[i].status = 'success';
|
||||
} catch (e) {
|
||||
uploadFiles[i].status = 'error';
|
||||
uploadFiles[i].error = e instanceof Error ? e.message : 'Upload failed';
|
||||
}
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
|
||||
// Clear successful uploads after a delay
|
||||
setTimeout(() => {
|
||||
uploadFiles
|
||||
.filter((f) => f.status === 'success')
|
||||
.forEach((f) => URL.revokeObjectURL(f.preview));
|
||||
uploadFiles = uploadFiles.filter((f) => f.status !== 'success');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function removeUpload(index: number) {
|
||||
URL.revokeObjectURL(uploadFiles[index].preview);
|
||||
uploadFiles = uploadFiles.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 p-4">
|
||||
<div class="flex gap-3 text-xs text-white/40">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="photos-list-view"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<!-- Drop Overlay -->
|
||||
{#if dragActive}
|
||||
<div class="drop-overlay">
|
||||
<UploadSimple size={40} weight="bold" />
|
||||
<span>Fotos ablegen</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<span>{albums.length} Alben</span>
|
||||
<span>{favorites.length} Favoriten</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<h3 class="mb-2 text-xs font-medium text-white/50">Alben</h3>
|
||||
<!-- Upload Previews -->
|
||||
{#if uploadFiles.length > 0}
|
||||
<div class="upload-section">
|
||||
<div class="upload-grid">
|
||||
{#each uploadFiles as uf, i (uf.preview)}
|
||||
<div
|
||||
class="upload-thumb"
|
||||
class:success={uf.status === 'success'}
|
||||
class:error={uf.status === 'error'}
|
||||
>
|
||||
<img src={uf.preview} alt="" />
|
||||
{#if uf.status === 'uploading'}
|
||||
<div class="upload-indicator">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if uf.status === 'success'}
|
||||
<div class="upload-indicator success">
|
||||
<Check size={14} weight="bold" />
|
||||
</div>
|
||||
{:else if uf.status === 'error'}
|
||||
<button
|
||||
class="upload-indicator error"
|
||||
onclick={() => removeUpload(i)}
|
||||
title={uf.error}
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Button -->
|
||||
<button class="upload-btn" onclick={() => fileInput?.click()}>
|
||||
<UploadSimple size={16} />
|
||||
<span>Fotos hochladen</span>
|
||||
</button>
|
||||
|
||||
<!-- Albums -->
|
||||
<div class="albums-section">
|
||||
<h3 class="section-label">Alben</h3>
|
||||
{#each albums as album (album.id)}
|
||||
<div
|
||||
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<p class="truncate text-sm font-medium text-white/80">{album.name}</p>
|
||||
{#if album.description}
|
||||
<p class="mt-1 truncate text-xs text-white/30">{album.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-white/40">
|
||||
{album.isAutoGenerated ? 'Auto-generiert' : 'Manuell'}
|
||||
</p>
|
||||
<div class="album-row">
|
||||
<div class="album-icon">
|
||||
<ImageSquare size={16} weight="regular" />
|
||||
</div>
|
||||
<div class="album-info">
|
||||
<p class="album-name">{album.name}</p>
|
||||
{#if album.description}
|
||||
<p class="album-desc">{album.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="album-badge">
|
||||
{album.isAutoGenerated ? 'Auto' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if albums.length === 0}
|
||||
<p class="py-8 text-center text-sm text-white/30">Keine Alben</p>
|
||||
<p class="empty-text">Keine Alben</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.photos-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Drop Overlay ──────────────────────────────── */
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 2px dashed var(--color-primary, #6366f1);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Stats ─────────────────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
|
||||
/* ── Upload Button ─────────────────────────────── */
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px dashed var(--color-border, rgba(255, 255, 255, 0.15));
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.5));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.upload-btn:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
/* ── Upload Previews ───────────────────────────── */
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.upload-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
.upload-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.upload-thumb.success {
|
||||
outline: 2px solid #22c55e;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.upload-thumb.error {
|
||||
outline: 2px solid var(--color-destructive, #ef4444);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.upload-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
.upload-indicator.success {
|
||||
background: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
.upload-indicator.error {
|
||||
background: rgba(239, 68, 68, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Albums ─────────────────────────────────────── */
|
||||
.albums-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.4));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.album-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.album-row:hover {
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.album-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.06));
|
||||
color: var(--color-muted-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground, rgba(255, 255, 255, 0.8));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.3));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-badge {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.3));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted-foreground, rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{habit ? `${habit.emoji} ${habit.title}` : 'Habit'} - ManaCore</title>
|
||||
<title>{habit ? habit.title : 'Habit'} - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="detail-page">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue