mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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">
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
251
packages/shared-icons/src/icon-registry.ts
Normal file
251
packages/shared-icons/src/icon-registry.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Curated Phosphor Icon Registry
|
||||
*
|
||||
* Provides a name→component 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);
|
||||
}
|
||||
|
|
@ -14,3 +14,5 @@
|
|||
*/
|
||||
|
||||
export * from 'phosphor-svelte';
|
||||
export { ICON_REGISTRY, ICON_CATEGORIES, getIconComponent, getAllIconNames } from './icon-registry';
|
||||
export type { IconName } from './icon-registry';
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
26
packages/shared-ui/src/atoms/DynamicIcon.svelte
Normal file
26
packages/shared-ui/src/atoms/DynamicIcon.svelte
Normal 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}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
3
packages/shared-ui/src/molecules/IconPicker.constants.ts
Normal file
3
packages/shared-ui/src/molecules/IconPicker.constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ICON_CATEGORIES, getAllIconNames } from '@manacore/shared-icons';
|
||||
|
||||
export const DEFAULT_ICON = 'star';
|
||||
145
packages/shared-ui/src/molecules/IconPicker.svelte
Normal file
145
packages/shared-ui/src/molecules/IconPicker.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
1072
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
1
services/mana-notify/.gitignore
vendored
1
services/mana-notify/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
bin/
|
||||
server
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue