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