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:
Till JS 2026-04-03 21:37:01 +02:00
parent ebfc2facaa
commit 8218037841
24 changed files with 1489 additions and 721 deletions

View file

@ -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} />

View file

@ -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,

View file

@ -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;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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';

View file

@ -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,

View file

@ -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, {

View file

@ -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',
};

View file

@ -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>

View file

@ -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">

View file

@ -257,7 +257,8 @@
"dev:times:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:times:web\"",
"dev:calc:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:calc:web\"",
"dev:manavoxel:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"",
"dev:manacore:servers": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\""
"dev:media": "cd services/mana-media && pnpm dev",
"dev:manacore:servers": "concurrently -n auth,sync,api,media -c blue,magenta,yellow,green \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\" \"pnpm dev:media\""
},
"devDependencies": {
"@manacore/eslint-config": "workspace:*",

View file

@ -0,0 +1,251 @@
/**
* Curated Phosphor Icon Registry
*
* Provides a namecomponent mapping for dynamic icon rendering
* and a categorized icon list for picker UIs.
*
* Named imports from the barrel export Vite/SvelteKit tree-shakes
* unused icons at build time, so only the ~73 curated icons ship.
*/
import type { Component } from 'svelte';
import {
Airplane,
Alarm,
AppleLogo,
Barbell,
Bed,
BeerStein,
Bell,
Bicycle,
BookOpen,
Brain,
CalendarBlank,
Camera,
Campfire,
CarSimple,
Cat,
ChatCircle,
CheckSquare,
Cigarette,
Clock,
CloudSun,
Coffee,
Confetti,
CookingPot,
Crown,
Dog,
Drop,
Envelope,
Eye,
FilmStrip,
Fire,
FirstAid,
Flag,
Flower,
ForkKnife,
GameController,
Globe,
GraduationCap,
Headphones,
Heart,
Heartbeat,
House,
Leaf,
Lightning,
MapPin,
Martini,
Medal,
Microphone,
Moon,
MusicNote,
Notebook,
PaintBrush,
Palette,
PawPrint,
PencilSimple,
PersonSimple,
PersonSimpleRun,
PersonSimpleTaiChi,
PersonSimpleWalk,
Pill,
Pizza,
Rocket,
Shower,
Smiley,
Sparkle,
Star,
Sun,
Sword,
Target,
Timer,
Tooth,
Tree,
Trophy,
Wind,
Wine,
} from 'phosphor-svelte';
// ─── Icon Registry ───────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = Component<any>;
export const ICON_REGISTRY: Record<string, IconComponent> = {
airplane: Airplane,
alarm: Alarm,
'apple-logo': AppleLogo,
barbell: Barbell,
bed: Bed,
'beer-stein': BeerStein,
bell: Bell,
bicycle: Bicycle,
'book-open': BookOpen,
brain: Brain,
'calendar-blank': CalendarBlank,
camera: Camera,
campfire: Campfire,
'car-simple': CarSimple,
cat: Cat,
'chat-circle': ChatCircle,
'check-square': CheckSquare,
cigarette: Cigarette,
clock: Clock,
'cloud-sun': CloudSun,
coffee: Coffee,
confetti: Confetti,
'cooking-pot': CookingPot,
crown: Crown,
dog: Dog,
drop: Drop,
envelope: Envelope,
eye: Eye,
'film-strip': FilmStrip,
fire: Fire,
'first-aid': FirstAid,
flag: Flag,
flower: Flower,
'fork-knife': ForkKnife,
'game-controller': GameController,
globe: Globe,
'graduation-cap': GraduationCap,
headphones: Headphones,
heart: Heart,
heartbeat: Heartbeat,
house: House,
leaf: Leaf,
lightning: Lightning,
'map-pin': MapPin,
martini: Martini,
medal: Medal,
microphone: Microphone,
moon: Moon,
'music-note': MusicNote,
notebook: Notebook,
'paint-brush': PaintBrush,
palette: Palette,
'paw-print': PawPrint,
'pencil-simple': PencilSimple,
'person-simple': PersonSimple,
'person-simple-run': PersonSimpleRun,
'person-simple-tai-chi': PersonSimpleTaiChi,
'person-simple-walk': PersonSimpleWalk,
pill: Pill,
pizza: Pizza,
rocket: Rocket,
shower: Shower,
smiley: Smiley,
sparkle: Sparkle,
star: Star,
sun: Sun,
sword: Sword,
target: Target,
timer: Timer,
tooth: Tooth,
tree: Tree,
trophy: Trophy,
wind: Wind,
wine: Wine,
};
export type IconName = keyof typeof ICON_REGISTRY;
// ─── Categories ──────────────────────────────────────────────
export const ICON_CATEGORIES: Record<string, string[]> = {
Gesundheit: ['heart', 'heartbeat', 'pill', 'first-aid', 'tooth', 'shower', 'bed'],
Aktivitat: [
'barbell',
'person-simple-run',
'person-simple-walk',
'bicycle',
'person-simple-tai-chi',
'person-simple',
'sword',
],
'Essen & Trinken': [
'coffee',
'drop',
'beer-stein',
'wine',
'martini',
'pizza',
'apple-logo',
'fork-knife',
'cooking-pot',
'cigarette',
],
Lernen: ['book-open', 'brain', 'pencil-simple', 'graduation-cap', 'notebook', 'eye', 'globe'],
Freizeit: [
'music-note',
'headphones',
'microphone',
'game-controller',
'film-strip',
'camera',
'palette',
'paint-brush',
],
Natur: ['tree', 'flower', 'leaf', 'sun', 'moon', 'cloud-sun', 'wind', 'campfire', 'paw-print'],
Produktivitat: [
'target',
'lightning',
'rocket',
'clock',
'timer',
'alarm',
'check-square',
'flag',
'calendar-blank',
],
Sonstiges: [
'star',
'sparkle',
'fire',
'smiley',
'confetti',
'trophy',
'medal',
'crown',
'bell',
'house',
'envelope',
'chat-circle',
'map-pin',
'airplane',
'car-simple',
'dog',
'cat',
],
};
// ─── Lookup Functions ────────────────────────────────────────
export function getIconComponent(name: string): IconComponent | null {
return ICON_REGISTRY[name] ?? null;
}
export function getAllIconNames(): string[] {
return Object.keys(ICON_REGISTRY);
}

View file

@ -14,3 +14,5 @@
*/
export * from 'phosphor-svelte';
export { ICON_REGISTRY, ICON_CATEGORIES, getIconComponent, getAllIconNames } from './icon-registry';
export type { IconName } from './icon-registry';

View file

@ -31,6 +31,11 @@
"svelte": "./src/dnd/index.ts",
"types": "./src/dnd/index.ts",
"default": "./src/dnd/index.ts"
},
"./toast": {
"svelte": "./src/toast/index.ts",
"types": "./src/toast/index.ts",
"default": "./src/toast/index.ts"
}
},
"scripts": {

View file

@ -0,0 +1,26 @@
<!--
DynamicIcon — renders a Phosphor icon by string name.
Uses the curated icon registry from @manacore/shared-icons.
Usage:
<DynamicIcon name="coffee" size={24} weight="bold" />
-->
<script lang="ts">
import { getIconComponent } from '@manacore/shared-icons';
interface Props {
name: string;
size?: number;
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
class?: string;
color?: string;
}
let { name, size = 24, weight = 'regular', class: className = '', color }: Props = $props();
let IconComponent = $derived(getIconComponent(name));
</script>
{#if IconComponent}
<IconComponent {size} {weight} class={className} {color} />
{/if}

View file

@ -2,3 +2,4 @@ export { default as Text } from './Text.svelte';
export { default as Button } from './Button.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Card } from './Card.svelte';
export { default as DynamicIcon } from './DynamicIcon.svelte';

View file

@ -0,0 +1,3 @@
export { ICON_CATEGORIES, getAllIconNames } from '@manacore/shared-icons';
export const DEFAULT_ICON = 'star';

View file

@ -0,0 +1,145 @@
<!--
IconPicker — reusable Phosphor icon picker with search and categories.
Follows the same pattern as ColorPicker (size variants, a11y, Tailwind).
-->
<script lang="ts">
import { ICON_CATEGORIES, getIconComponent, type IconName } from '@manacore/shared-icons';
import { Check } from '@manacore/shared-icons';
interface Props {
selectedIcon?: string;
onIconChange: (icon: string) => void;
size?: 'sm' | 'md' | 'lg';
label?: string;
showSearch?: boolean;
showCategories?: boolean;
}
let {
selectedIcon,
onIconChange,
size = 'md',
label = 'Icon wählen',
showSearch = true,
showCategories = true,
}: Props = $props();
let searchQuery = $state('');
const sizeClasses: Record<string, string> = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
};
const iconSizes: Record<string, number> = {
sm: 16,
md: 20,
lg: 24,
};
const checkSizes: Record<string, number> = {
sm: 8,
md: 10,
lg: 12,
};
const gapClasses: Record<string, string> = {
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
};
let filteredCategories = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (!query) return ICON_CATEGORIES;
const result: Record<string, string[]> = {};
for (const [category, icons] of Object.entries(ICON_CATEGORIES)) {
const matched = icons.filter((name) => name.includes(query));
if (matched.length > 0) {
result[category] = matched;
}
}
return result;
});
function handleKeyDown(e: KeyboardEvent, iconName: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onIconChange(iconName);
}
}
</script>
<div class="flex flex-col gap-2" role="group" aria-label={label}>
{#if showSearch}
<input
type="text"
class="w-full rounded-md border border-white/10 bg-transparent px-3 py-1.5 text-sm
text-[var(--color-foreground,#fff)] placeholder-[var(--color-muted-foreground,#888)]
outline-none focus:border-[var(--color-primary,#6366f1)]"
placeholder="Icon suchen..."
bind:value={searchQuery}
/>
{/if}
{#each Object.entries(filteredCategories) as [category, icons]}
<div>
{#if showCategories}
<div
class="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--color-muted-foreground,#888)]"
>
{category}
</div>
{/if}
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label={category}>
{#each icons as iconName}
{@const isSelected = selectedIcon === iconName}
{@const IconComp = getIconComponent(iconName)}
{#if IconComp}
<button
type="button"
class="
{sizeClasses[size]}
relative rounded-lg
flex items-center justify-center
transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#6366f1)]
{isSelected
? 'bg-[var(--color-primary,#6366f1)]/20 ring-2 ring-[var(--color-primary,#6366f1)] scale-110'
: 'bg-white/5 hover:bg-white/10 hover:scale-110'}
"
onclick={() => onIconChange(iconName)}
onkeydown={(e) => handleKeyDown(e, iconName)}
role="radio"
aria-checked={isSelected}
aria-label={iconName}
title={iconName}
>
<IconComp
size={iconSizes[size]}
weight={isSelected ? 'bold' : 'regular'}
class="text-[var(--color-foreground,#fff)]"
/>
{#if isSelected}
<div
class="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center
rounded-full bg-[var(--color-primary,#6366f1)]"
>
<Check size={checkSizes[size]} weight="bold" class="text-white" />
</div>
{/if}
</button>
{/if}
{/each}
</div>
</div>
{/each}
{#if Object.keys(filteredCategories).length === 0}
<p class="py-2 text-center text-sm text-[var(--color-muted-foreground,#888)]">
Kein Icon gefunden
</p>
{/if}
</div>

View file

@ -7,6 +7,8 @@ export { default as FilterDropdown } from './FilterDropdown.svelte';
export { default as FavoriteButton } from './FavoriteButton.svelte';
export { default as ColorPicker } from './ColorPicker.svelte';
export { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants';
export { default as IconPicker } from './IconPicker.svelte';
export { DEFAULT_ICON } from './IconPicker.constants';
export { default as ReminderPicker } from './ReminderPicker.svelte';
export type { SelectOption } from './Select.types';
export type { FilterDropdownOption } from './FilterDropdown.types';

View file

@ -56,7 +56,7 @@ export function setSecurityHeaders(response: Response, options: SecurityHeadersO
"default-src 'self'",
`script-src 'self' 'unsafe-inline' https://stats.mana.how https://glitchtip.mana.how ${scriptSrc.join(' ')}`.trim(),
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: https: ${imgSrc.join(' ')}`.trim(),
`img-src 'self' data: blob: https: ${imgSrc.join(' ')}`.trim(),
`connect-src 'self' https://stats.mana.how https://glitchtip.mana.how ${connectSrc.join(' ')}`.trim(),
`font-src 'self' ${fontSrc.join(' ')}`.trim(),
mediaSrc.length > 0 ? `media-src 'self' ${mediaSrc.join(' ')}`.trim() : '',

1072
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1 +1,2 @@
bin/
server