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

@ -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() : '',