feat(inventar): add configurable inventory management app

SvelteKit web app with schema-less collections, 8 field types,
8 templates (electronics, books, furniture, etc.), 3 views (list/grid/table),
hierarchical locations, categories, full-text search, and localStorage persistence.
Includes ManaScore audit (28/100 Alpha), Dockerfile, SSO prep, and i18n (DE/EN).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 11:21:36 +01:00
parent 2e4bb9bad7
commit 86d1da3587
65 changed files with 5050 additions and 0 deletions

59
apps/inventar/CLAUDE.md Normal file
View file

@ -0,0 +1,59 @@
# Inventar
Configurable inventory management app - track anything with custom schemas.
**Web App Port:** 5190
## Project Overview
Inventar is a schema-less inventory management system built with SvelteKit. Users can create collections with custom field definitions, organize items by location and category, and view them in list/grid/table views.
### Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
| State | Svelte 5 runes ($state, $derived) with localStorage persistence |
| Icons | @manacore/shared-icons (Phosphor) |
| PWA | @vite-pwa/sveltekit + Workbox |
| i18n | svelte-i18n (de, en) |
## Key Concepts
- **Collections**: Groups of items with a shared schema (custom field definitions)
- **Templates**: Predefined schemas for common item types (electronics, books, etc.)
- **Items**: Individual inventory entries with custom field values
- **Locations**: Hierarchical places (House > Room > Cabinet > Shelf)
- **Categories**: Flexible categorization with hierarchy
- **Views**: List, Grid, Table views with saved filters
## Development
```bash
# From monorepo root
pnpm dev:inventar:web # Start web app on port 5190
```
## Project Structure
```
apps/inventar/
├── apps/
│ └── web/ # SvelteKit web client
│ ├── src/
│ │ ├── routes/
│ │ │ ├── (auth)/ # Login flow
│ │ │ └── (app)/ # Authenticated app
│ │ │ ├── collections/
│ │ │ ├── items/
│ │ │ ├── locations/
│ │ │ └── categories/
│ │ └── lib/
│ │ ├── stores/ # Svelte 5 rune stores
│ │ ├── components/ # UI components
│ │ ├── i18n/ # Translations
│ │ └── data/ # Templates, defaults
│ └── static/
└── packages/
└── shared/ # Shared types & constants
```

View file

@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
# Build stage - inherits pre-built shared packages from sveltekit-base
FROM sveltekit-base:local AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Copy app-specific packages
COPY apps/inventar/packages/shared ./apps/inventar/packages/shared
COPY apps/inventar/apps/web ./apps/inventar/apps/web
# Install app-specific dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
# Build the web app
WORKDIR /app/apps/inventar/apps/web
RUN pnpm exec svelte-kit sync
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/inventar/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/inventar/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/inventar/apps/web/build ./build
COPY --from=builder /app/apps/inventar/apps/web/package.json ./
# Expose port
EXPOSE 5190
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5190
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5190/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,50 @@
{
"name": "@inventar/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:unit": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.15.29",
"svelte": "^5.41.0",
"svelte-check": "^4.2.1",
"tailwindcss": "^4.1.7",
"typescript": "^5.9.3",
"vite": "^6.3.5",
"vitest": "^3.2.1",
"@vite-pwa/sveltekit": "^1.1.0",
"@manacore/shared-vite-config": "workspace:*",
"@manacore/shared-tailwind": "workspace:*"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-landing-ui": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@inventar/shared": "workspace:*",
"date-fns": "^4.1.0",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,54 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
:root {
--primary: 38 92% 50%;
--primary-foreground: 0 0% 100%;
--accent: 38 92% 50%;
--accent-foreground: 0 0% 100%;
}
/* Status colors */
.status-owned { color: #22c55e; }
.status-lent { color: #f59e0b; }
.status-stored { color: #3b82f6; }
.status-for-sale { color: #a855f7; }
.status-disposed { color: #6b7280; }
/* View transitions */
.view-transition {
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* Grid view cards */
.item-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.item-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Field editor animations */
.field-enter {
animation: fieldSlideIn 0.2s ease forwards;
}
@keyframes fieldSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Location tree indentation */
.location-tree-item {
transition: background-color 0.15s ease;
}
.location-tree-item:hover {
background-color: hsl(var(--accent) / 0.1);
}

16
apps/inventar/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
declare const __BUILD_HASH__: string;
declare const __BUILD_TIME__: string;
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f59e0b" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<title>Inventar</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
import type { HandleClientError } from '@sveltejs/kit';
import { initErrorTracking, createErrorHandler } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'inventar-web',
});
const errorHandler = createErrorHandler('inventar-web');
export const handleError: HandleClientError = ({ error, event }) => {
errorHandler(error, { url: event.url.pathname });
};

View file

@ -0,0 +1,33 @@
import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
const injectRuntimeEnv: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const glitchtipDsn = process.env.PUBLIC_GLITCHTIP_DSN || '';
return html.replace(
'</head>',
`<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__="${authUrl}";
window.__PUBLIC_GLITCHTIP_DSN__="${glitchtipDsn}";
</script></head>`
);
},
});
// Security headers
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ${authUrl}; font-src 'self' data:;`
);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
};
export const handle = sequence(injectRuntimeEnv);

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type { ItemStatus } from '@inventar/shared';
import { _ } from 'svelte-i18n';
interface Props {
status: ItemStatus;
size?: 'sm' | 'md';
}
let { status, size = 'sm' }: Props = $props();
const statusColors: Record<ItemStatus, string> = {
owned: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
lent: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400',
stored: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
for_sale: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
disposed: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
};
</script>
<span
class="inline-block rounded-full font-medium {statusColors[status]} {size === 'sm'
? 'px-2 py-0.5 text-xs'
: 'px-3 py-1 text-sm'}"
>
{$_(`status.${status}`)}
</span>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import type { ViewMode } from '@inventar/shared';
import { _ } from 'svelte-i18n';
interface Props {
current: ViewMode;
onchange: (mode: ViewMode) => void;
}
let { current, onchange }: Props = $props();
const modes: { value: ViewMode; icon: string }[] = [
{ value: 'list', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' },
{
value: 'grid',
icon: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
},
{ value: 'table', icon: 'M3 10h18M3 14h18M3 18h18M3 6h18M3 6v12M21 6v12M9 6v12M15 6v12' },
];
</script>
<div class="flex rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
{#each modes as mode}
<button
type="button"
onclick={() => onchange(mode.value)}
class="p-2 transition-colors {current === mode.value
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'} {mode.value ===
'list'
? 'rounded-l-lg'
: mode.value === 'table'
? 'rounded-r-lg'
: ''}"
title={$_(`view.${mode.value}`)}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mode.icon} />
</svg>
</button>
{/each}
</div>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import type { FieldDefinition } from '@inventar/shared';
interface Props {
field: FieldDefinition;
value: unknown;
onchange: (value: unknown) => void;
}
let { field, value, onchange }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
switch (field.type) {
case 'number':
case 'currency':
onchange(target.value ? Number(target.value) : undefined);
break;
case 'checkbox':
onchange(target.checked);
break;
default:
onchange(target.value || undefined);
}
}
function handleSelectChange(e: Event) {
const target = e.target as HTMLSelectElement;
onchange(target.value || undefined);
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:ring-offset-1';
</script>
{#if field.type === 'text'}
<input
type="text"
value={String(value || '')}
placeholder={field.placeholder || field.name}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'number'}
<input
type="number"
value={value !== undefined && value !== null ? Number(value) : ''}
placeholder={field.placeholder || field.name}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'currency'}
<div class="flex gap-2">
<input
type="number"
step="0.01"
value={value !== undefined && value !== null ? Number(value) : ''}
placeholder="0.00"
class="{inputClass} flex-1"
oninput={handleInput}
/>
<span class="flex items-center text-sm text-[hsl(var(--muted-foreground))]">
{field.currencyCode || 'EUR'}
</span>
</div>
{:else if field.type === 'date'}
<input type="date" value={String(value || '')} class={inputClass} oninput={handleInput} />
{:else if field.type === 'checkbox'}
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={!!value}
class="h-4 w-4 rounded border-[hsl(var(--border))] text-[hsl(var(--primary))]"
onchange={handleInput}
/>
<span class="text-sm text-[hsl(var(--foreground))]">{field.name}</span>
</label>
{:else if field.type === 'select'}
<select value={String(value || '')} class={inputClass} onchange={handleSelectChange}>
<option value="">— Auswahlen —</option>
{#each field.options || [] as option}
<option value={option}>{option}</option>
{/each}
</select>
{:else if field.type === 'url'}
<input
type="url"
value={String(value || '')}
placeholder={field.placeholder || 'https://...'}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'tags'}
{@const currentTags = Array.isArray(value) ? (value as string[]) : []}
<div class="space-y-2">
<div class="flex flex-wrap gap-1">
{#each currentTags as tag, i}
<span
class="inline-flex items-center gap-1 rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
onclick={() => onchange(currentTags.filter((_, idx) => idx !== i))}>x</button
>
</span>
{/each}
</div>
<input
type="text"
placeholder="Tag eingeben + Enter"
class={inputClass}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const target = e.target as HTMLInputElement;
const newTag = target.value.trim();
if (newTag && !currentTags.includes(newTag)) {
onchange([...currentTags, newTag]);
target.value = '';
}
}
}}
/>
</div>
{/if}

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { FieldDefinition } from '@inventar/shared';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
field: FieldDefinition;
value: unknown;
}
let { field, value }: Props = $props();
function formatCurrency(val: unknown, code?: string): string {
const num = Number(val);
if (isNaN(num)) return String(val || '');
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: code || 'EUR' }).format(
num
);
}
function formatDate(val: unknown): string {
if (!val) return '';
try {
return format(new Date(String(val)), 'dd.MM.yyyy', { locale: de });
} catch {
return String(val);
}
}
</script>
{#if value === undefined || value === null || value === ''}
<span class="text-[hsl(var(--muted-foreground))] italic"></span>
{:else if field.type === 'checkbox'}
{#if value}
<span class="text-green-500"></span>
{:else}
<span class="text-[hsl(var(--muted-foreground))]"></span>
{/if}
{:else if field.type === 'currency'}
<span>{formatCurrency(value, field.currencyCode)}</span>
{:else if field.type === 'date'}
<span>{formatDate(value)}</span>
{:else if field.type === 'url'}
<a
href={String(value)}
target="_blank"
rel="noopener noreferrer"
class="text-[hsl(var(--primary))] underline hover:no-underline"
>
{String(value)
.replace(/^https?:\/\//, '')
.slice(0, 40)}
</a>
{:else if field.type === 'select'}
<span
class="inline-block rounded-full bg-[hsl(var(--accent)/0.15)] px-2 py-0.5 text-xs font-medium text-[hsl(var(--accent-foreground))]"
>
{String(value)}
</span>
{:else if field.type === 'tags'}
<div class="flex flex-wrap gap-1">
{#each Array.isArray(value) ? value : [] as tag}
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs">{tag}</span>
{/each}
</div>
{:else if field.type === 'number'}
<span>{Number(value).toLocaleString('de-DE')}</span>
{:else}
<span>{String(value)}</span>
{/if}

View file

@ -0,0 +1,225 @@
<script lang="ts">
import type { FieldDefinition, FieldType } from '@inventar/shared';
import { _ } from 'svelte-i18n';
interface Props {
fields: FieldDefinition[];
onchange: (fields: FieldDefinition[]) => void;
}
let { fields, onchange }: Props = $props();
const fieldTypes: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Zahl' },
{ value: 'date', label: 'Datum' },
{ value: 'select', label: 'Auswahl' },
{ value: 'tags', label: 'Tags' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'url', label: 'URL' },
{ value: 'currency', label: 'Wahrung' },
];
function addField() {
const newField: FieldDefinition = {
id: crypto.randomUUID(),
name: '',
type: 'text',
order: fields.length,
};
onchange([...fields, newField]);
}
function updateField(id: string, updates: Partial<FieldDefinition>) {
onchange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
}
function removeField(id: string) {
onchange(fields.filter((f) => f.id !== id).map((f, i) => ({ ...f, order: i })));
}
function moveField(id: string, direction: 'up' | 'down') {
const index = fields.findIndex((f) => f.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
const newFields = [...fields];
[newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]];
onchange(newFields.map((f, i) => ({ ...f, order: i })));
}
let editingOptions = $state<string | null>(null);
let newOption = $state('');
function addOption(fieldId: string) {
if (!newOption.trim()) return;
const field = fields.find((f) => f.id === fieldId);
if (!field) return;
updateField(fieldId, { options: [...(field.options || []), newOption.trim()] });
newOption = '';
}
function removeOption(fieldId: string, index: number) {
const field = fields.find((f) => f.id === fieldId);
if (!field) return;
updateField(fieldId, { options: field.options?.filter((_, i) => i !== index) });
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<div class="space-y-3">
{#each fields.sort((a, b) => a.order - b.order) as field, index (field.id)}
<div
class="field-enter rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3"
>
<div class="flex items-start gap-2">
<!-- Reorder buttons -->
<div class="flex flex-col gap-0.5 pt-1">
<button
type="button"
onclick={() => moveField(field.id, 'up')}
disabled={index === 0}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
>&#9650;</button
>
<button
type="button"
onclick={() => moveField(field.id, 'down')}
disabled={index === fields.length - 1}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
>&#9660;</button
>
</div>
<!-- Field config -->
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<input
type="text"
value={field.name}
placeholder="Feldname"
class="{inputClass} flex-1"
oninput={(e) => updateField(field.id, { name: (e.target as HTMLInputElement).value })}
/>
<select
value={field.type}
class="{inputClass} w-32"
onchange={(e) =>
updateField(field.id, { type: (e.target as HTMLSelectElement).value as FieldType })}
>
{#each fieldTypes as ft}
<option value={ft.value}>{ft.label}</option>
{/each}
</select>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-1 text-xs text-[hsl(var(--muted-foreground))]">
<input
type="checkbox"
checked={field.required || false}
onchange={(e) =>
updateField(field.id, { required: (e.target as HTMLInputElement).checked })}
class="h-3 w-3"
/>
Pflichtfeld
</label>
<input
type="text"
value={field.placeholder || ''}
placeholder="Platzhalter (optional)"
class="{inputClass} flex-1 text-xs"
oninput={(e) =>
updateField(field.id, {
placeholder: (e.target as HTMLInputElement).value || undefined,
})}
/>
</div>
<!-- Currency code for currency fields -->
{#if field.type === 'currency'}
<input
type="text"
value={field.currencyCode || 'EUR'}
placeholder="Wahrungscode (z.B. EUR)"
class="{inputClass} text-xs"
oninput={(e) =>
updateField(field.id, { currencyCode: (e.target as HTMLInputElement).value })}
/>
{/if}
<!-- Options for select fields -->
{#if field.type === 'select'}
<div class="space-y-1">
<div class="flex flex-wrap gap-1">
{#each field.options || [] as option, i}
<span
class="inline-flex items-center gap-1 rounded bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>
{option}
<button
type="button"
onclick={() => removeOption(field.id, i)}
class="text-[hsl(var(--muted-foreground))] hover:text-red-500">x</button
>
</span>
{/each}
</div>
<div class="flex gap-1">
<input
type="text"
bind:value={newOption}
placeholder="Neue Option"
class="{inputClass} flex-1 text-xs"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOption(field.id);
}
}}
/>
<button
type="button"
onclick={() => addOption(field.id)}
class="rounded bg-[hsl(var(--primary))] px-2 py-1 text-xs text-[hsl(var(--primary-foreground))]"
>+</button
>
</div>
</div>
{/if}
</div>
<!-- Delete button -->
<button
type="button"
onclick={() => removeField(field.id)}
class="mt-1 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title={$_('field.removeField')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{/each}
<button
type="button"
onclick={addField}
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary))] hover:text-[hsl(var(--primary))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$_('collection.addField')}
</button>
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 6 }: Props = $props();
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(count) as _}
<div
class="animate-pulse rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5"
>
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-lg bg-[hsl(var(--muted))]"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-32 rounded bg-[hsl(var(--muted))]"></div>
<div class="h-3 w-48 rounded bg-[hsl(var(--muted))]"></div>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="h-3 w-16 rounded bg-[hsl(var(--muted))]"></div>
<div class="flex gap-1">
<div class="h-4 w-12 rounded bg-[hsl(var(--muted))]"></div>
<div class="h-4 w-12 rounded bg-[hsl(var(--muted))]"></div>
</div>
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 8 }: Props = $props();
</script>
<div class="space-y-2">
{#each Array(count) as _}
<div
class="animate-pulse flex items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>
<div class="flex-1 space-y-2">
<div class="flex items-center gap-2">
<div class="h-4 w-40 rounded bg-[hsl(var(--muted))]"></div>
<div class="h-5 w-16 rounded-full bg-[hsl(var(--muted))]"></div>
</div>
<div class="flex gap-3">
<div class="h-3 w-24 rounded bg-[hsl(var(--muted))]"></div>
<div class="h-3 w-20 rounded bg-[hsl(var(--muted))]"></div>
</div>
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,2 @@
export { default as CollectionListSkeleton } from './CollectionListSkeleton.svelte';
export { default as ItemListSkeleton } from './ItemListSkeleton.svelte';

View file

@ -0,0 +1,38 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('inventar_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('inventar_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,148 @@
{
"app": {
"name": "Inventar",
"loading": "Laden..."
},
"nav": {
"collections": "Sammlungen",
"allItems": "Alle Items",
"locations": "Standorte",
"categories": "Kategorien",
"tags": "Tags",
"search": "Suche",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"collection": {
"create": "Sammlung erstellen",
"edit": "Sammlung bearbeiten",
"delete": "Sammlung löschen",
"name": "Name",
"description": "Beschreibung",
"icon": "Symbol",
"color": "Farbe",
"template": "Vorlage",
"schema": "Schema",
"noCollections": "Keine Sammlungen",
"itemCount": "{count} Items",
"selectTemplate": "Vorlage wählen",
"customFields": "Eigene Felder",
"addField": "Feld hinzufügen"
},
"item": {
"create": "Item erstellen",
"edit": "Item bearbeiten",
"delete": "Item löschen",
"name": "Name",
"description": "Beschreibung",
"status": "Status",
"quantity": "Menge",
"location": "Standort",
"category": "Kategorie",
"tags": "Tags",
"photos": "Fotos",
"notes": "Notizen",
"documents": "Dokumente",
"noItems": "Keine Items",
"addPhoto": "Foto hinzufügen",
"addNote": "Notiz hinzufügen",
"addDocument": "Dokument hinzufügen"
},
"status": {
"owned": "Besitzt",
"lent": "Verliehen",
"stored": "Eingelagert",
"for_sale": "Zu verkaufen",
"disposed": "Entsorgt"
},
"location": {
"create": "Standort erstellen",
"edit": "Standort bearbeiten",
"delete": "Standort löschen",
"name": "Name",
"parent": "Übergeordnet",
"noLocations": "Keine Standorte",
"addSub": "Unterstandort hinzufügen"
},
"category": {
"create": "Kategorie erstellen",
"edit": "Kategorie bearbeiten",
"delete": "Kategorie löschen",
"name": "Name",
"noCategories": "Keine Kategorien"
},
"field": {
"text": "Text",
"number": "Zahl",
"date": "Datum",
"select": "Auswahl",
"tags": "Tags",
"checkbox": "Checkbox",
"url": "URL",
"currency": "Währung",
"required": "Pflichtfeld",
"placeholder": "Platzhalter",
"options": "Optionen",
"addOption": "Option hinzufügen",
"removeField": "Feld entfernen"
},
"template": {
"electronics": "Elektronik",
"books": "Bücher",
"furniture": "Möbel",
"clothing": "Kleidung",
"tools": "Werkzeug",
"kitchen": "Küche",
"media": "Medien",
"custom": "Benutzerdefiniert"
},
"view": {
"list": "Liste",
"grid": "Kacheln",
"table": "Tabelle"
},
"filter": {
"all": "Alle",
"saved": "Gespeicherte Filter",
"save": "Filter speichern",
"clear": "Filter zurücksetzen"
},
"purchase": {
"title": "Kaufdaten",
"price": "Preis",
"currency": "Währung",
"date": "Kaufdatum",
"retailer": "Händler",
"warranty": "Garantie bis",
"receipt": "Beleg"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren",
"email": "E-Mail",
"password": "Passwort",
"forgotPassword": "Passwort vergessen?"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"close": "Schließen",
"search": "Suchen",
"error": "Fehler",
"success": "Erfolgreich",
"loading": "Laden...",
"noResults": "Keine Ergebnisse",
"confirm": "Bestätigen",
"back": "Zurück",
"next": "Weiter",
"create": "Erstellen"
},
"error": {
"notFound": "Seite nicht gefunden",
"backToHome": "Zurück zur Startseite"
}
}

View file

@ -0,0 +1,148 @@
{
"app": {
"name": "Inventar",
"loading": "Loading..."
},
"nav": {
"collections": "Collections",
"allItems": "All Items",
"locations": "Locations",
"categories": "Categories",
"tags": "Tags",
"search": "Search",
"settings": "Settings",
"feedback": "Feedback"
},
"collection": {
"create": "Create Collection",
"edit": "Edit Collection",
"delete": "Delete Collection",
"name": "Name",
"description": "Description",
"icon": "Icon",
"color": "Color",
"template": "Template",
"schema": "Schema",
"noCollections": "No collections",
"itemCount": "{count} items",
"selectTemplate": "Select template",
"customFields": "Custom fields",
"addField": "Add field"
},
"item": {
"create": "Create Item",
"edit": "Edit Item",
"delete": "Delete Item",
"name": "Name",
"description": "Description",
"status": "Status",
"quantity": "Quantity",
"location": "Location",
"category": "Category",
"tags": "Tags",
"photos": "Photos",
"notes": "Notes",
"documents": "Documents",
"noItems": "No items",
"addPhoto": "Add photo",
"addNote": "Add note",
"addDocument": "Add document"
},
"status": {
"owned": "Owned",
"lent": "Lent",
"stored": "Stored",
"for_sale": "For Sale",
"disposed": "Disposed"
},
"location": {
"create": "Create Location",
"edit": "Edit Location",
"delete": "Delete Location",
"name": "Name",
"parent": "Parent",
"noLocations": "No locations",
"addSub": "Add sub-location"
},
"category": {
"create": "Create Category",
"edit": "Edit Category",
"delete": "Delete Category",
"name": "Name",
"noCategories": "No categories"
},
"field": {
"text": "Text",
"number": "Number",
"date": "Date",
"select": "Select",
"tags": "Tags",
"checkbox": "Checkbox",
"url": "URL",
"currency": "Currency",
"required": "Required",
"placeholder": "Placeholder",
"options": "Options",
"addOption": "Add option",
"removeField": "Remove field"
},
"template": {
"electronics": "Electronics",
"books": "Books",
"furniture": "Furniture",
"clothing": "Clothing",
"tools": "Tools",
"kitchen": "Kitchen",
"media": "Media",
"custom": "Custom"
},
"view": {
"list": "List",
"grid": "Grid",
"table": "Table"
},
"filter": {
"all": "All",
"saved": "Saved Filters",
"save": "Save Filter",
"clear": "Clear Filters"
},
"purchase": {
"title": "Purchase Data",
"price": "Price",
"currency": "Currency",
"date": "Purchase Date",
"retailer": "Retailer",
"warranty": "Warranty Until",
"receipt": "Receipt"
},
"auth": {
"login": "Login",
"logout": "Logout",
"register": "Register",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password?"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"close": "Close",
"search": "Search",
"error": "Error",
"success": "Success",
"loading": "Loading...",
"noResults": "No results",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"create": "Create"
},
"error": {
"notFound": "Page not found",
"backToHome": "Back to home"
}
}

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
export const feedbackService = createFeedbackService({
appId: 'inventar',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,237 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
const DEV_AUTH_URL = 'http://localhost:3001';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
return import.meta.env.DEV ? DEV_AUTH_URL : '';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL;
}
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
getAuthService();
return _tokenManager;
}
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
let authenticated = await authService.isAuthenticated();
if (!authenticated) {
const ssoResult = await authService.trySSO();
if (ssoResult.success) authenticated = true;
}
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
try {
const result = await authService.signIn(email, password);
if (!result.success) return { success: false, error: result.error || 'Login failed' };
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService)
return { success: false, error: 'Auth not available on server', needsVerification: false };
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.signUp(email, password, sourceAppUrl);
if (!result.success)
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
if (result.needsVerification) return { success: true, needsVerification: true };
const signInResult = await authStore.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
needsVerification: false,
};
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
} catch (error) {
console.error('Sign out error:', error);
}
user = null;
},
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
try {
const redirectTo = browser ? window.location.origin : undefined;
const result = await authService.forgotPassword(email, redirectTo);
return result.success
? { success: true }
: { success: false, error: result.error || 'Password reset failed' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
},
async resetPasswordWithToken(token: string, newPassword: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
try {
const result = await authService.resetPassword(token, newPassword);
return result.success
? { success: true }
: { success: false, error: result.error || 'Failed to reset password' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
},
async verifyTwoFactor(code: string, trustDevice?: boolean) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
const result = await authService.verifyTwoFactor(code, trustDevice);
if (result.success) {
const userData = await authService.getUserFromToken();
user = userData;
}
return result;
},
async verifyBackupCode(code: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
const result = await authService.verifyBackupCode(code);
if (result.success) {
const userData = await authService.getUserFromToken();
user = userData;
}
return result;
},
async sendMagicLink(email: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
return authService.sendMagicLink(email);
},
isPasskeyAvailable(): boolean {
const authService = getAuthService();
if (!authService) return false;
return authService.isPasskeyAvailable();
},
async signInWithPasskey() {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
try {
const result = await authService.signInWithPasskey();
if (!result.success)
return { success: false, error: result.error || 'Passkey authentication failed' };
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
},
async getAccessToken() {
const authService = getAuthService();
if (!authService) return null;
return await authService.getAppToken();
},
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) return null;
return await tokenManager.getValidToken();
},
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' };
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
return result.success
? { success: true }
: { success: false, error: result.error || 'Failed to resend verification email' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
},
};

View file

@ -0,0 +1,100 @@
import { browser } from '$app/environment';
import type { Category } from '@inventar/shared';
const STORAGE_KEY = 'inventar_categories';
function loadFromStorage(): Category[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(categories: Category[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(categories));
}
function generateId(): string {
return crypto.randomUUID();
}
let categories = $state<Category[]>([]);
let initialized = $state(false);
export const categoriesStore = {
get categories() {
return categories;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
categories = loadFromStorage();
initialized = true;
},
getById(id: string): Category | undefined {
return categories.find((c) => c.id === id);
},
getRootCategories(): Category[] {
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
},
getChildren(parentId: string): Category[] {
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
},
getTree(): Category[] {
const buildTree = (parentId?: string): Category[] => {
return categories
.filter((c) => c.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, children: buildTree(c.id) }));
};
return buildTree(undefined);
},
create(data: { name: string; icon?: string; color?: string; parentId?: string }): Category {
const now = new Date().toISOString();
const siblings = categories.filter((c) => c.parentId === data.parentId);
const category: Category = {
id: generateId(),
parentId: data.parentId,
name: data.name,
icon: data.icon,
color: data.color,
order: siblings.length,
createdAt: now,
updatedAt: now,
};
categories = [...categories, category];
saveToStorage(categories);
return category;
},
update(id: string, data: Partial<Pick<Category, 'name' | 'icon' | 'color'>>) {
categories = categories.map((c) =>
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
);
saveToStorage(categories);
},
delete(id: string) {
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
categories.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
};
collectIds(id);
categories = categories.filter((c) => !idsToDelete.has(c.id));
saveToStorage(categories);
},
};

View file

@ -0,0 +1,102 @@
import { browser } from '$app/environment';
import type { Collection, CollectionSchema } from '@inventar/shared';
const STORAGE_KEY = 'inventar_collections';
function loadFromStorage(): Collection[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(collections: Collection[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(collections));
}
function generateId(): string {
return crypto.randomUUID();
}
let collections = $state<Collection[]>([]);
let initialized = $state(false);
export const collectionsStore = {
get collections() {
return collections;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
collections = loadFromStorage();
initialized = true;
},
getById(id: string): Collection | undefined {
return collections.find((c) => c.id === id);
},
create(data: {
name: string;
description?: string;
icon?: string;
color?: string;
schema: CollectionSchema;
templateId?: string;
}): Collection {
const now = new Date().toISOString();
const collection: Collection = {
id: generateId(),
name: data.name,
description: data.description,
icon: data.icon,
color: data.color,
schema: data.schema,
templateId: data.templateId,
order: collections.length,
itemCount: 0,
createdAt: now,
updatedAt: now,
};
collections = [...collections, collection];
saveToStorage(collections);
return collection;
},
update(
id: string,
data: Partial<Pick<Collection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
) {
collections = collections.map((c) =>
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
);
saveToStorage(collections);
},
delete(id: string) {
collections = collections.filter((c) => c.id !== id);
saveToStorage(collections);
},
reorder(orderedIds: string[]) {
collections = orderedIds
.map((id, index) => {
const c = collections.find((col) => col.id === id);
return c ? { ...c, order: index } : null;
})
.filter((c): c is Collection => c !== null);
saveToStorage(collections);
},
updateItemCount(collectionId: string, count: number) {
collections = collections.map((c) => (c.id === collectionId ? { ...c, itemCount: count } : c));
saveToStorage(collections);
},
};

View file

@ -0,0 +1,260 @@
import { browser } from '$app/environment';
import type {
Item,
ItemStatus,
ItemNote,
ItemPhoto,
PurchaseData,
SortOption,
} from '@inventar/shared';
const STORAGE_KEY = 'inventar_items';
function loadFromStorage(): Item[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(items: Item[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
function generateId(): string {
return crypto.randomUUID();
}
let items = $state<Item[]>([]);
let initialized = $state(false);
export const itemsStore = {
get items() {
return items;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
items = loadFromStorage();
initialized = true;
},
getById(id: string): Item | undefined {
return items.find((i) => i.id === id);
},
getByCollection(collectionId: string): Item[] {
return items.filter((i) => i.collectionId === collectionId);
},
getFiltered(filters: {
collectionId?: string;
locationId?: string;
categoryId?: string;
status?: ItemStatus[];
search?: string;
tagIds?: string[];
}): Item[] {
let result = items;
if (filters.collectionId) {
result = result.filter((i) => i.collectionId === filters.collectionId);
}
if (filters.locationId) {
result = result.filter((i) => i.locationId === filters.locationId);
}
if (filters.categoryId) {
result = result.filter((i) => i.categoryId === filters.categoryId);
}
if (filters.status?.length) {
result = result.filter((i) => filters.status!.includes(i.status));
}
if (filters.tagIds?.length) {
result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t)));
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description?.toLowerCase().includes(q) ||
Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q))
);
}
return result;
},
getSorted(itemList: Item[], sort: SortOption): Item[] {
return [...itemList].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'createdAt':
cmp = a.createdAt.localeCompare(b.createdAt);
break;
case 'updatedAt':
cmp = a.updatedAt.localeCompare(b.updatedAt);
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'quantity':
cmp = a.quantity - b.quantity;
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
},
create(data: {
collectionId: string;
name: string;
description?: string;
status?: ItemStatus;
quantity?: number;
locationId?: string;
categoryId?: string;
fieldValues?: Record<string, unknown>;
purchaseData?: PurchaseData;
tags?: string[];
}): Item {
const now = new Date().toISOString();
const item: Item = {
id: generateId(),
collectionId: data.collectionId,
name: data.name,
description: data.description,
status: data.status || 'owned',
quantity: data.quantity || 1,
locationId: data.locationId,
categoryId: data.categoryId,
fieldValues: data.fieldValues || {},
purchaseData: data.purchaseData,
photos: [],
notes: [],
documents: [],
tags: data.tags || [],
order: items.filter((i) => i.collectionId === data.collectionId).length,
createdAt: now,
updatedAt: now,
};
items = [...items, item];
saveToStorage(items);
return item;
},
update(
id: string,
data: Partial<
Pick<
Item,
| 'name'
| 'description'
| 'status'
| 'quantity'
| 'locationId'
| 'categoryId'
| 'fieldValues'
| 'purchaseData'
| 'tags'
>
>
) {
items = items.map((i) =>
i.id === id ? { ...i, ...data, updatedAt: new Date().toISOString() } : i
);
saveToStorage(items);
},
delete(id: string) {
items = items.filter((i) => i.id !== id);
saveToStorage(items);
},
deleteByCollection(collectionId: string) {
items = items.filter((i) => i.collectionId !== collectionId);
saveToStorage(items);
},
addNote(itemId: string, content: string) {
const now = new Date().toISOString();
const note: ItemNote = { id: generateId(), content, createdAt: now, updatedAt: now };
items = items.map((i) =>
i.id === itemId ? { ...i, notes: [...i.notes, note], updatedAt: now } : i
);
saveToStorage(items);
},
updateNote(itemId: string, noteId: string, content: string) {
const now = new Date().toISOString();
items = items.map((i) =>
i.id === itemId
? {
...i,
notes: i.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)),
updatedAt: now,
}
: i
);
saveToStorage(items);
},
deleteNote(itemId: string, noteId: string) {
items = items.map((i) =>
i.id === itemId
? {
...i,
notes: i.notes.filter((n) => n.id !== noteId),
updatedAt: new Date().toISOString(),
}
: i
);
saveToStorage(items);
},
addPhoto(itemId: string, photo: Omit<ItemPhoto, 'id' | 'order'>) {
const item = items.find((i) => i.id === itemId);
const newPhoto: ItemPhoto = { ...photo, id: generateId(), order: item?.photos.length || 0 };
items = items.map((i) =>
i.id === itemId
? { ...i, photos: [...i.photos, newPhoto], updatedAt: new Date().toISOString() }
: i
);
saveToStorage(items);
},
deletePhoto(itemId: string, photoId: string) {
items = items.map((i) =>
i.id === itemId
? {
...i,
photos: i.photos.filter((p) => p.id !== photoId),
updatedAt: new Date().toISOString(),
}
: i
);
saveToStorage(items);
},
getCountByCollection(collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
},
getTotalCount(): number {
return items.length;
},
getCountByStatus(status: ItemStatus): number {
return items.filter((i) => i.status === status).length;
},
};

View file

@ -0,0 +1,124 @@
import { browser } from '$app/environment';
import type { Location } from '@inventar/shared';
const STORAGE_KEY = 'inventar_locations';
function loadFromStorage(): Location[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(locations: Location[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(locations));
}
function generateId(): string {
return crypto.randomUUID();
}
function buildPath(locations: Location[], parentId?: string): string {
if (!parentId) return '';
const parent = locations.find((l) => l.id === parentId);
if (!parent) return '';
return parent.path ? `${parent.path}/${parent.name}` : parent.name;
}
function getDepth(locations: Location[], parentId?: string): number {
if (!parentId) return 0;
const parent = locations.find((l) => l.id === parentId);
return parent ? parent.depth + 1 : 0;
}
let locations = $state<Location[]>([]);
let initialized = $state(false);
export const locationsStore = {
get locations() {
return locations;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
locations = loadFromStorage();
initialized = true;
},
getById(id: string): Location | undefined {
return locations.find((l) => l.id === id);
},
getRootLocations(): Location[] {
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
},
getChildren(parentId: string): Location[] {
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
},
getTree(): Location[] {
const buildTree = (parentId?: string): Location[] => {
return locations
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((l) => ({ ...l, children: buildTree(l.id) }));
};
return buildTree(undefined);
},
getFullPath(id: string): string {
const location = locations.find((l) => l.id === id);
if (!location) return '';
return location.path ? `${location.path}/${location.name}` : location.name;
},
create(data: { name: string; description?: string; icon?: string; parentId?: string }): Location {
const now = new Date().toISOString();
const path = buildPath(locations, data.parentId);
const depth = getDepth(locations, data.parentId);
const siblings = locations.filter((l) => l.parentId === data.parentId);
const location: Location = {
id: generateId(),
parentId: data.parentId,
name: data.name,
description: data.description,
icon: data.icon,
path,
depth,
order: siblings.length,
createdAt: now,
updatedAt: now,
};
locations = [...locations, location];
saveToStorage(locations);
return location;
},
update(id: string, data: Partial<Pick<Location, 'name' | 'description' | 'icon'>>) {
locations = locations.map((l) =>
l.id === id ? { ...l, ...data, updatedAt: new Date().toISOString() } : l
);
saveToStorage(locations);
},
delete(id: string) {
// Delete location and all children
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
locations.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
};
collectIds(id);
locations = locations.filter((l) => !idsToDelete.has(l.id));
saveToStorage(locations);
},
};

View file

@ -0,0 +1,6 @@
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({
withToolbar: true,
toolbarCollapsedDefault: true,
});

View file

@ -0,0 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'inventar',
defaultVariant: 'ocean',
});

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
export const userSettings = createUserSettingsStore({
appId: 'inventar',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,105 @@
import { browser } from '$app/environment';
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@inventar/shared';
const VIEW_KEY = 'inventar_view_mode';
const SORT_KEY = 'inventar_sort';
const FILTERS_KEY = 'inventar_saved_filters';
function load<T>(key: string, fallback: T): T {
if (!browser) return fallback;
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : fallback;
} catch {
return fallback;
}
}
function save(key: string, value: unknown) {
if (!browser) return;
localStorage.setItem(key, JSON.stringify(value));
}
let viewMode = $state<ViewMode>('list');
let sort = $state<SortOption>({ field: 'name', direction: 'asc' });
let activeFilters = $state<FilterCriteria>({});
let savedFilters = $state<SavedFilter[]>([]);
let initialized = $state(false);
export const viewStore = {
get viewMode() {
return viewMode;
},
get sort() {
return sort;
},
get activeFilters() {
return activeFilters;
},
get savedFilters() {
return savedFilters;
},
get hasActiveFilters() {
return !!(
activeFilters.search ||
activeFilters.status?.length ||
activeFilters.locationId ||
activeFilters.categoryId ||
activeFilters.tagIds?.length ||
activeFilters.collectionId
);
},
initialize() {
if (initialized) return;
viewMode = load<ViewMode>(VIEW_KEY, 'list');
sort = load<SortOption>(SORT_KEY, { field: 'name', direction: 'asc' });
savedFilters = load<SavedFilter[]>(FILTERS_KEY, []);
initialized = true;
},
setViewMode(mode: ViewMode) {
viewMode = mode;
save(VIEW_KEY, mode);
},
setSort(newSort: SortOption) {
sort = newSort;
save(SORT_KEY, newSort);
},
setFilters(filters: FilterCriteria) {
activeFilters = filters;
},
updateFilter<K extends keyof FilterCriteria>(key: K, value: FilterCriteria[K]) {
activeFilters = { ...activeFilters, [key]: value };
},
clearFilters() {
activeFilters = {};
},
saveFilter(name: string) {
const filter: SavedFilter = {
id: crypto.randomUUID(),
name,
criteria: { ...activeFilters },
createdAt: new Date().toISOString(),
};
savedFilters = [...savedFilters, filter];
save(FILTERS_KEY, savedFilters);
},
loadFilter(id: string) {
const filter = savedFilters.find((f) => f.id === id);
if (filter) {
activeFilters = { ...filter.criteria };
}
},
deleteSavedFilter(id: string) {
savedFilters = savedFilters.filter((f) => f.id !== id);
save(FILTERS_KEY, savedFilters);
},
};

View file

@ -0,0 +1,4 @@
export const APP_VERSION = '1.0.0';
export const BUILD_TIME: string =
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales } from '$lib/i18n';
import { PillNavigation } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
let { children } = $props();
let showNav = $state(true);
let initialized = $state(false);
// Initialize stores
$effect(() => {
if (authStore.initialized && !initialized) {
collectionsStore.initialize();
itemsStore.initialize();
locationsStore.initialize();
categoriesStore.initialize();
viewStore.initialize();
initialized = true;
}
});
// Auth gate
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
const navItems = [
{ href: '/', label: 'Sammlungen', icon: 'archive' },
{ href: '/items', label: 'Alle Items', icon: 'list' },
{ href: '/locations', label: 'Standorte', icon: 'map-pin' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/mana', label: 'Mana', icon: 'star' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
function handleLogout() {
authStore.signOut();
goto('/login');
}
</script>
{#if !authStore.initialized}
<div class="flex min-h-screen items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
></div>
</div>
{:else if !authStore.isAuthenticated}
<div class="flex min-h-screen items-center justify-center">
<p class="text-[hsl(var(--muted-foreground))]">Weiterleitung...</p>
</div>
{:else}
<div class="flex min-h-screen flex-col">
<!-- Top Navigation -->
{#if showNav}
<nav
class="sticky top-0 z-40 border-b border-[hsl(var(--border))] bg-[hsl(var(--background))/0.95] backdrop-blur"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-2">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<span class="text-xl">📦</span>
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Inventar</span>
</a>
<!-- Nav Items -->
<div class="hidden items-center gap-1 md:flex">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {$page.url.pathname ===
item.href ||
($page.url.pathname.startsWith(item.href) && item.href !== '/')
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)] hover:text-[hsl(var(--foreground))]'}"
>
{item.label}
</a>
{/each}
</div>
<!-- Right side -->
<div class="flex items-center gap-2">
<!-- Language -->
<select
class="rounded-lg border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xs text-[hsl(var(--muted-foreground))]"
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
>
{#each supportedLocales as loc}
<option value={loc}>{loc.toUpperCase()}</option>
{/each}
</select>
<!-- User menu -->
<a
href="/profile"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</a>
<button
onclick={handleLogout}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Abmelden"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
<!-- Mobile nav -->
<div class="flex gap-1 overflow-x-auto px-4 pb-2 md:hidden">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="shrink-0 rounded-full px-3 py-1 text-xs transition-colors {$page.url
.pathname === item.href
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'}"
>
{item.label}
</a>
{/each}
</div>
</nav>
{/if}
<!-- Content -->
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6">
{@render children()}
</main>
</div>
<!-- FAB for mobile - New Item -->
<button
onclick={() => goto('/items?action=new')}
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] shadow-lg transition-transform hover:scale-105 md:hidden"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
{/if}

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import type { Collection } from '@inventar/shared';
function getItemCount(collectionId: string): number {
return itemsStore.getCountByCollection(collectionId);
}
function handleCollectionClick(collection: Collection) {
goto(`/collections/${collection.id}`);
}
function handleDelete(e: Event, id: string) {
e.stopPropagation();
if (confirm('Sammlung und alle Items löschen?')) {
itemsStore.deleteByCollection(id);
collectionsStore.delete(id);
}
}
// Stats
let totalItems = $derived(itemsStore.getTotalCount());
let totalCollections = $derived(collectionsStore.collections.length);
</script>
<svelte:head>
<title>Inventar</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.collections')}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{totalCollections} Sammlungen · {totalItems} Items
</p>
</div>
<a
href="/collections/new"
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$_('collection.create')}
</a>
</div>
<!-- Collections grid -->
{#if collectionsStore.collections.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-5xl">📦</span>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">
{$_('collection.noCollections')}
</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Erstelle deine erste Sammlung, um loszulegen.
</p>
<a
href="/collections/new"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
{$_('collection.create')}
</a>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each collectionsStore.collections.sort((a, b) => a.order - b.order) as collection (collection.id)}
<div
role="button"
tabindex="0"
onclick={() => handleCollectionClick(collection)}
class="item-card group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">{collection.icon || '📁'}</span>
<div>
<h3 class="font-semibold text-[hsl(var(--foreground))]">{collection.name}</h3>
{#if collection.description}
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))] line-clamp-1">
{collection.description}
</p>
{/if}
</div>
</div>
<button
onclick={(e) => handleDelete(e, collection.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{getItemCount(collection.id)} Items
</span>
<div class="flex gap-1">
{#each collection.schema.fields.slice(0, 3) as field}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
{field.name}
</span>
{/each}
{#if collection.schema.fields.length > 3}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
+{collection.schema.fields.length - 3}
</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { Category } from '@inventar/shared';
let showForm = $state(false);
let editingId = $state<string | null>(null);
let name = $state('');
let icon = $state('');
let color = $state('');
function startCreate() {
name = '';
icon = '';
color = '';
editingId = null;
showForm = true;
}
function startEdit(category: Category) {
editingId = category.id;
name = category.name;
icon = category.icon || '';
color = category.color || '';
showForm = true;
}
function save() {
if (!name.trim()) return;
if (editingId) {
categoriesStore.update(editingId, {
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
});
} else {
categoriesStore.create({
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
});
}
showForm = false;
}
function deleteCategory(id: string) {
if (confirm('Kategorie löschen?')) {
categoriesStore.delete(id);
}
}
const inputClass =
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>{$_('nav.categories')} | Inventar</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.categories')}</h1>
<button
onclick={startCreate}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$_('category.create')}
</button>
</div>
{#if showForm}
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
<div class="flex gap-2">
<input
type="text"
bind:value={icon}
placeholder="🏷️"
class="{inputClass} w-12 text-center text-lg"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder={$_('category.name')}
class="{inputClass} flex-1"
onkeydown={(e) => e.key === 'Enter' && save()}
/>
<input
type="color"
bind:value={color}
class="h-9 w-9 cursor-pointer rounded-lg border border-[hsl(var(--border))]"
/>
<button
onclick={save}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>{$_('common.save')}</button
>
<button
onclick={() => (showForm = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm"
>{$_('common.cancel')}</button
>
</div>
</div>
{/if}
{#if categoriesStore.categories.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-4xl">🏷️</span>
<p class="text-[hsl(var(--muted-foreground))]">{$_('category.noCategories')}</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each categoriesStore.categories.sort((a, b) => a.order - b.order) as category (category.id)}
<div
class="group flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>
<span class="text-xl">{category.icon || '🏷️'}</span>
{#if category.color}
<span class="h-3 w-3 rounded-full" style="background-color: {category.color}"></span>
{/if}
<span class="flex-1 font-medium text-[hsl(var(--foreground))]">{category.name}</span>
<button
onclick={() => startEdit(category)}
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-[hsl(var(--foreground))] group-hover:opacity-100"
>✎</button
>
<button
onclick={() => deleteCategory(category.id)}
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>×</button
>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,311 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { Item, ItemStatus } from '@inventar/shared';
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
let collectionId = $derived($page.params.id);
let collection = $derived(collectionsStore.getById(collectionId));
let items = $derived(itemsStore.getByCollection(collectionId));
let sortedItems = $derived(itemsStore.getSorted(items, viewStore.sort));
// Item creation
let showNewItem = $state(false);
let newItemName = $state('');
let newItemFields = $state<Record<string, unknown>>({});
let newItemStatus = $state<ItemStatus>('owned');
function createItem() {
if (!newItemName.trim() || !collection) return;
itemsStore.create({
collectionId: collection.id,
name: newItemName.trim(),
status: newItemStatus,
fieldValues: newItemFields,
});
newItemName = '';
newItemFields = {};
newItemStatus = 'owned';
showNewItem = false;
}
function deleteItem(e: Event, id: string) {
e.stopPropagation();
if (confirm('Item löschen?')) {
itemsStore.delete(id);
}
}
</script>
<svelte:head>
<title>{collection?.name || 'Sammlung'} | Inventar</title>
</svelte:head>
{#if !collection}
<div class="text-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
<a href="/" class="mt-4 text-[hsl(var(--primary))]">Zurück</a>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/" class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
<span class="text-2xl">{collection.icon || '📁'}</span>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{collection.name}</h1>
{#if collection.description}
<p class="text-sm text-[hsl(var(--muted-foreground))]">{collection.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<ViewModeToggle current={viewStore.viewMode} onchange={(m) => viewStore.setViewMode(m)} />
<a
href="/collections/{collection.id}/edit"
class="rounded-lg border border-[hsl(var(--border))] p-2 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</a>
<button
onclick={() => (showNewItem = !showNewItem)}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{$_('item.create')}
</button>
</div>
</div>
<!-- New Item Form -->
{#if showNewItem}
<div
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
bind:value={newItemName}
placeholder={$_('item.name')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
onkeydown={(e) => e.key === 'Enter' && createItem()}
/>
<!-- Custom fields -->
{#if collection.schema.fields.length > 0}
<div class="grid gap-3 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
{field.name}{field.required ? ' *' : ''}
</label>
<FieldEditor
{field}
value={newItemFields[field.id]}
onchange={(v) => (newItemFields = { ...newItemFields, [field.id]: v })}
/>
</div>
{/each}
</div>
{/if}
<div class="flex justify-end gap-2">
<button
onclick={() => (showNewItem = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
>
{$_('common.cancel')}
</button>
<button
onclick={createItem}
disabled={!newItemName.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{$_('common.create')}
</button>
</div>
</div>
{/if}
<!-- Items -->
{#if sortedItems.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-4xl">📭</span>
<p class="text-[hsl(var(--muted-foreground))]">{$_('item.noItems')}</p>
<button
onclick={() => (showNewItem = true)}
class="mt-4 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm text-[hsl(var(--primary-foreground))]"
>
{$_('item.create')}
</button>
</div>
{:else if viewStore.viewMode === 'grid'}
<!-- Grid View -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each sortedItems as item (item.id)}
<button
onclick={() => goto(`/items/${item.id}`)}
class="item-card group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
>
<div class="flex items-start justify-between">
<h3 class="font-semibold text-[hsl(var(--foreground))]">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
{#if collection.schema.fields.length > 0}
<div class="mt-3 space-y-1">
{#each collection.schema.fields.slice(0, 3) as field}
{#if item.fieldValues[field.id] !== undefined}
<div class="flex items-baseline gap-2 text-xs">
<span class="text-[hsl(var(--muted-foreground))]">{field.name}:</span>
<FieldRenderer {field} value={item.fieldValues[field.id]} />
</div>
{/if}
{/each}
</div>
{/if}
<button
onclick={(e) => deleteItem(e, item.id)}
class="mt-2 text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>
{$_('common.delete')}
</button>
</button>
{/each}
</div>
{:else if viewStore.viewMode === 'table'}
<!-- Table View -->
<div class="overflow-x-auto rounded-xl border border-[hsl(var(--border))]">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[hsl(var(--border))] bg-[hsl(var(--muted))]">
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.name')}</th
>
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.status')}</th
>
{#each collection.schema.fields as field}
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}</th
>
{/each}
<th class="w-10"></th>
</tr>
</thead>
<tbody>
{#each sortedItems as item (item.id)}
<tr
class="cursor-pointer border-b border-[hsl(var(--border))] transition-colors hover:bg-[hsl(var(--accent)/0.05)]"
onclick={() => goto(`/items/${item.id}`)}
>
<td class="px-4 py-2.5 font-medium text-[hsl(var(--foreground))]">{item.name}</td>
<td class="px-4 py-2.5"><StatusBadge status={item.status} /></td>
{#each collection.schema.fields as field}
<td class="px-4 py-2.5"
><FieldRenderer {field} value={item.fieldValues[field.id]} /></td
>
{/each}
<td class="px-4 py-2.5">
<button
onclick={(e) => deleteItem(e, item.id)}
class="text-[hsl(var(--muted-foreground))] hover:text-red-500"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- List View -->
<div class="space-y-2">
{#each sortedItems as item (item.id)}
<button
onclick={() => goto(`/items/${item.id}`)}
class="group flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))] truncate">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
{#if collection.schema.fields.length > 0}
<div class="mt-1 flex flex-wrap gap-3 text-xs text-[hsl(var(--muted-foreground))]">
{#each collection.schema.fields.slice(0, 4) as field}
{#if item.fieldValues[field.id] !== undefined}
<span
>{field.name}: <FieldRenderer
{field}
value={item.fieldValues[field.id]}
/></span
>
{/if}
{/each}
</div>
{/if}
</div>
{#if item.quantity > 1}
<span class="text-sm text-[hsl(var(--muted-foreground))]">×{item.quantity}</span>
{/if}
<button
onclick={(e) => deleteItem(e, item.id)}
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</button>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { collectionsStore } from '$lib/stores/collections.svelte';
import type { CollectionSchema } from '@inventar/shared';
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
let collectionId = $derived($page.params.id);
let collection = $derived(collectionsStore.getById(collectionId));
let name = $state('');
let description = $state('');
let icon = $state('');
let schema = $state<CollectionSchema>({ fields: [] });
let loaded = $state(false);
$effect(() => {
if (collection && !loaded) {
name = collection.name;
description = collection.description || '';
icon = collection.icon || '';
schema = { fields: [...collection.schema.fields] };
loaded = true;
}
});
function handleSave() {
if (!collection || !name.trim()) return;
collectionsStore.update(collection.id, {
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
schema,
});
goto(`/collections/${collection.id}`);
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>{$_('collection.edit')} | Inventar</title>
</svelte:head>
{#if !collection}
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
{:else}
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center gap-3">
<button
onclick={() => goto(`/collections/${collection.id}`)}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('collection.edit')}</h1>
</div>
<div class="space-y-4">
<div class="flex gap-3">
<input
type="text"
bind:value={icon}
placeholder="📁"
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder={$_('collection.name')}
class="{inputClass} flex-1"
/>
</div>
<textarea
bind:value={description}
placeholder={$_('collection.description')}
rows="2"
class={inputClass}
></textarea>
<div>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
{$_('collection.customFields')}
</h3>
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
</div>
<div class="flex justify-end gap-3 pt-4">
<button
onclick={() => goto(`/collections/${collection.id}`)}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
>
{$_('common.cancel')}
</button>
<button
onclick={handleSave}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{$_('common.save')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { DEFAULT_TEMPLATES } from '@inventar/shared';
import type { CollectionSchema, Template } from '@inventar/shared';
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
let step = $state<'template' | 'details'>('template');
let selectedTemplate = $state<Template | null>(null);
let name = $state('');
let description = $state('');
let icon = $state('');
let schema = $state<CollectionSchema>({ fields: [] });
function selectTemplate(template: Template) {
selectedTemplate = template;
if (template.id !== 'custom') {
name = template.name;
icon = template.icon;
}
schema = { fields: [...template.schema.fields] };
step = 'details';
}
function handleCreate() {
if (!name.trim()) return;
collectionsStore.create({
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
schema,
templateId: selectedTemplate?.id,
});
goto('/');
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>{$_('collection.create')} | Inventar</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center gap-3">
<button
onclick={() => (step === 'details' && selectedTemplate ? (step = 'template') : goto('/'))}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('collection.create')}</h1>
</div>
{#if step === 'template'}
<!-- Template Selection -->
<div>
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
{$_('collection.selectTemplate')}
</h2>
<div class="grid gap-3 sm:grid-cols-2">
{#each DEFAULT_TEMPLATES as template}
<button
onclick={() => selectTemplate(template)}
class="item-card rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-center gap-3">
<span class="text-2xl">{template.icon}</span>
<div>
<h3 class="font-semibold text-[hsl(var(--foreground))]">{template.name}</h3>
<p class="text-xs text-[hsl(var(--muted-foreground))]">{template.description}</p>
</div>
</div>
{#if template.schema.fields.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each template.schema.fields as field}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
{field.name}
</span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
</div>
{:else}
<!-- Collection Details -->
<div class="space-y-4">
<div class="flex gap-3">
<div class="flex-shrink-0">
<input
type="text"
bind:value={icon}
placeholder="📁"
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
maxlength="2"
/>
</div>
<input
type="text"
bind:value={name}
placeholder={$_('collection.name')}
class="{inputClass} flex-1"
/>
</div>
<textarea
bind:value={description}
placeholder={$_('collection.description')}
rows="2"
class={inputClass}
></textarea>
<!-- Schema Editor -->
<div>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
{$_('collection.customFields')}
</h3>
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
onclick={() => goto('/')}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
>
{$_('common.cancel')}
</button>
<button
onclick={handleCreate}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{$_('common.create')}
</button>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { feedbackService } from '$lib/services/feedback';
</script>
<FeedbackPage {feedbackService} user={authStore.user} appName="Inventar" />

View file

@ -0,0 +1,5 @@
<script lang="ts">
import { HelpPage } from '@manacore/shared-ui';
</script>
<HelpPage appName="Inventar" defaultSection="faq" />

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
import type { ItemStatus } from '@inventar/shared';
let searchQuery = $state('');
let filteredItems = $derived(
itemsStore.getFiltered({
search: searchQuery || undefined,
...viewStore.activeFilters,
})
);
let sortedItems = $derived(itemsStore.getSorted(filteredItems, viewStore.sort));
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
function toggleStatus(status: ItemStatus) {
const current = viewStore.activeFilters.status || [];
if (current.includes(status)) {
viewStore.updateFilter(
'status',
current.filter((s) => s !== status)
);
} else {
viewStore.updateFilter('status', [...current, status]);
}
}
function getCollectionName(collectionId: string): string {
return collectionsStore.getById(collectionId)?.name || '';
}
</script>
<svelte:head>
<title>{$_('nav.allItems')} | Inventar</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.allItems')}</h1>
<ViewModeToggle current={viewStore.viewMode} onchange={(m) => viewStore.setViewMode(m)} />
</div>
<!-- Search & Filters -->
<div class="space-y-3">
<div class="relative">
<svg
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="{$_('common.search')}..."
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-2.5 pl-10 pr-4 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Status filter chips -->
<div class="flex flex-wrap gap-2">
{#each statuses as status}
<button
onclick={() => toggleStatus(status)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {(
viewStore.activeFilters.status || []
).includes(status)
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.2)]'}"
>
{$_(`status.${status}`)}
</button>
{/each}
{#if viewStore.hasActiveFilters}
<button
onclick={() => viewStore.clearFilters()}
class="rounded-full px-3 py-1 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>
{$_('filter.clear')}
</button>
{/if}
</div>
</div>
<!-- Results -->
<p class="text-sm text-[hsl(var(--muted-foreground))]">{sortedItems.length} Items</p>
{#if sortedItems.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<span class="mb-4 text-4xl">🔍</span>
<p class="text-[hsl(var(--muted-foreground))]">{$_('common.noResults')}</p>
</div>
{:else if viewStore.viewMode === 'grid'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each sortedItems as item (item.id)}
<button
onclick={() => goto(`/items/${item.id}`)}
class="item-card rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
>
<div class="flex items-start justify-between">
<h3 class="font-semibold text-[hsl(var(--foreground))]">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{getCollectionName(item.collectionId)}
</p>
</button>
{/each}
</div>
{:else}
<div class="space-y-2">
{#each sortedItems as item (item.id)}
<button
onclick={() => goto(`/items/${item.id}`)}
class="flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))] truncate">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]">
{getCollectionName(item.collectionId)}
</p>
</div>
{#if item.quantity > 1}
<span class="text-sm text-[hsl(var(--muted-foreground))]">×{item.quantity}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,311 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { ItemStatus } from '@inventar/shared';
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
let itemId = $derived($page.params.id);
let item = $derived(itemsStore.getById(itemId));
let collection = $derived(item ? collectionsStore.getById(item.collectionId) : undefined);
let editing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editStatus = $state<ItemStatus>('owned');
let editQuantity = $state(1);
let editFields = $state<Record<string, unknown>>({});
let editLocationId = $state<string | undefined>();
let editCategoryId = $state<string | undefined>();
// Notes
let newNote = $state('');
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
function startEditing() {
if (!item) return;
editName = item.name;
editDescription = item.description || '';
editStatus = item.status;
editQuantity = item.quantity;
editFields = { ...item.fieldValues };
editLocationId = item.locationId;
editCategoryId = item.categoryId;
editing = true;
}
function saveEdit() {
if (!item || !editName.trim()) return;
itemsStore.update(item.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
status: editStatus,
quantity: editQuantity,
fieldValues: editFields,
locationId: editLocationId,
categoryId: editCategoryId,
});
editing = false;
}
function addNote() {
if (!item || !newNote.trim()) return;
itemsStore.addNote(item.id, newNote.trim());
newNote = '';
}
function deleteItem() {
if (!item || !confirm('Item endgültig löschen?')) return;
itemsStore.delete(item.id);
goto(collection ? `/collections/${collection.id}` : '/items');
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>{item?.name || 'Item'} | Inventar</title>
</svelte:head>
{#if !item}
<div class="text-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Item nicht gefunden</p>
<a href="/items" class="mt-4 text-[hsl(var(--primary))]">Zurück</a>
</div>
{:else}
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button
onclick={() => goto(collection ? `/collections/${collection.id}` : '/items')}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{#if !editing}
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{item.name}</h1>
{#if collection}
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{collection.icon}
{collection.name}
</p>
{/if}
</div>
{/if}
</div>
<div class="flex gap-2">
{#if editing}
<button
onclick={() => (editing = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
>{$_('common.cancel')}</button
>
<button
onclick={saveEdit}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.save')}</button
>
{:else}
<button
onclick={startEditing}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--foreground))]"
>{$_('common.edit')}</button
>
<button
onclick={deleteItem}
class="rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>{$_('common.delete')}</button
>
{/if}
</div>
</div>
{#if editing}
<!-- Edit Mode -->
<div
class="space-y-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5"
>
<input type="text" bind:value={editName} placeholder={$_('item.name')} class={inputClass} />
<textarea
bind:value={editDescription}
placeholder={$_('item.description')}
rows="2"
class={inputClass}
></textarea>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.status')}</label
>
<select bind:value={editStatus} class={inputClass}>
{#each statuses as s}<option value={s}>{$_(`status.${s}`)}</option>{/each}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.quantity')}</label
>
<input type="number" bind:value={editQuantity} min="1" class={inputClass} />
</div>
{#if locationsStore.locations.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.location')}</label
>
<select bind:value={editLocationId} class={inputClass}>
<option value={undefined}> Kein Standort —</option>
{#each locationsStore.locations as loc}
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
{/each}
</select>
</div>
{/if}
{#if categoriesStore.categories.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.category')}</label
>
<select bind:value={editCategoryId} class={inputClass}>
<option value={undefined}> Keine Kategorie —</option>
{#each categoriesStore.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
{/if}
</div>
{#if collection}
<div>
<h3 class="mb-2 text-sm font-semibold text-[hsl(var(--foreground))]">
{$_('collection.customFields')}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}</label
>
<FieldEditor
{field}
value={editFields[field.id]}
onchange={(v) => (editFields = { ...editFields, [field.id]: v })}
/>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<!-- View Mode -->
<div class="space-y-4">
<!-- Status & Meta -->
<div class="flex flex-wrap items-center gap-3">
<StatusBadge status={item.status} size="md" />
{#if item.quantity > 1}
<span class="rounded-full bg-[hsl(var(--muted))] px-3 py-1 text-sm"
>×{item.quantity}</span
>
{/if}
{#if item.locationId}
{@const loc = locationsStore.getById(item.locationId)}
{#if loc}
<span class="flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))]">
📍 {locationsStore.getFullPath(loc.id)}
</span>
{/if}
{/if}
{#if item.categoryId}
{@const cat = categoriesStore.getById(item.categoryId)}
{#if cat}
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>{cat.icon || '🏷️'} {cat.name}</span
>
{/if}
{/if}
</div>
{#if item.description}
<p class="text-[hsl(var(--foreground))]">{item.description}</p>
{/if}
<!-- Custom Fields -->
{#if collection && collection.schema.fields.length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">Details</h3>
<div class="grid gap-2 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div class="flex items-baseline gap-2">
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}:</span
>
<FieldRenderer {field} value={item.fieldValues[field.id]} />
</div>
{/each}
</div>
</div>
{/if}
<!-- Notes -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
{$_('item.notes')} ({item.notes.length})
</h3>
<div class="space-y-2">
{#each item.notes as note (note.id)}
<div
class="group flex items-start justify-between rounded-lg bg-[hsl(var(--muted))] p-3"
>
<div>
<p class="text-sm text-[hsl(var(--foreground))]">{note.content}</p>
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{new Date(note.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
<button
onclick={() => itemsStore.deleteNote(item.id, note.id)}
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>×</button
>
</div>
{/each}
</div>
<div class="mt-3 flex gap-2">
<input
type="text"
bind:value={newNote}
placeholder="Notiz hinzufügen..."
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
onkeydown={(e) => e.key === 'Enter' && addNote()}
/>
<button
onclick={addNote}
disabled={!newNote.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-2 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>+</button
>
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { locationsStore } from '$lib/stores/locations.svelte';
import type { Location } from '@inventar/shared';
let showForm = $state(false);
let editingId = $state<string | null>(null);
let parentId = $state<string | undefined>();
let name = $state('');
let icon = $state('');
function startCreate(parent?: string) {
parentId = parent;
name = '';
icon = '';
editingId = null;
showForm = true;
}
function startEdit(location: Location) {
editingId = location.id;
name = location.name;
icon = location.icon || '';
parentId = location.parentId;
showForm = true;
}
function save() {
if (!name.trim()) return;
if (editingId) {
locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
} else {
locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
}
showForm = false;
name = '';
icon = '';
editingId = null;
}
function deleteLocation(id: string) {
if (confirm('Standort und alle Unterstandorte löschen?')) {
locationsStore.delete(id);
}
}
let tree = $derived(locationsStore.getTree());
const inputClass =
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>{$_('nav.locations')} | Inventar</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.locations')}</h1>
<button
onclick={() => startCreate()}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$_('location.create')}
</button>
</div>
{#if showForm}
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold">
{editingId ? $_('location.edit') : $_('location.create')}
</h3>
<div class="flex gap-2">
<input
type="text"
bind:value={icon}
placeholder="📍"
class="{inputClass} w-12 text-center text-lg"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder={$_('location.name')}
class="{inputClass} flex-1"
onkeydown={(e) => e.key === 'Enter' && save()}
/>
<button
onclick={save}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{$_('common.save')}
</button>
<button
onclick={() => (showForm = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm"
>
{$_('common.cancel')}
</button>
</div>
</div>
{/if}
{#if tree.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-4xl">📍</span>
<p class="text-[hsl(var(--muted-foreground))]">{$_('location.noLocations')}</p>
</div>
{:else}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
{#snippet renderTree(locations: Location[], depth: number)}
{#each locations as location (location.id)}
<div class="location-tree-item border-b border-[hsl(var(--border))] last:border-b-0">
<div
class="flex items-center gap-2 px-4 py-2.5"
style="padding-left: {16 + depth * 24}px"
>
<span class="text-lg">{location.icon || '📍'}</span>
<span class="flex-1 text-sm font-medium text-[hsl(var(--foreground))]"
>{location.name}</span
>
<button
onclick={() => startCreate(location.id)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
title={$_('location.addSub')}>+</button
>
<button
onclick={() => startEdit(location)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>✎</button
>
<button
onclick={() => deleteLocation(location.id)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-red-500"
>×</button
>
</div>
{#if location.children?.length}
{@render renderTree(location.children, depth + 1)}
{/if}
</div>
{/each}
{/snippet}
{@render renderTree(tree, 0)}
</div>
{/if}
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
alert(`Abo "${planId}" ausgewählt. Bezahlsystem wird noch integriert.`);
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
alert(`Paket "${packageId}" ausgewählt. Bezahlsystem wird noch integriert.`);
}
</script>
<svelte:head>
<title>Mana - Inventar</title>
</svelte:head>
<div class="mana-page">
<SubscriptionPage
appName="Inventar"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="20% Rabatt"
/>
</div>
<style>
.mana-page {
min-height: 100%;
width: 100%;
overflow-x: hidden;
background-color: hsl(var(--background));
}
</style>

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<ProfilePage user={authStore.user} appName="Inventar" />

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
let query = $state('');
let results = $derived(query.length >= 2 ? itemsStore.getFiltered({ search: query }) : []);
</script>
<svelte:head>
<title>{$_('nav.search')} | Inventar</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.search')}</h1>
<div class="relative">
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={query}
placeholder="{$_('common.search')} in allen Items..."
class="w-full rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-3 pl-11 pr-4 text-lg text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
autofocus
/>
</div>
{#if query.length >= 2}
<p class="text-sm text-[hsl(var(--muted-foreground))]">{results.length} Ergebnisse</p>
<div class="space-y-2">
{#each results as item (item.id)}
<button
onclick={() => goto(`/items/${item.id}`)}
class="flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))]">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{collectionsStore.getById(item.collectionId)?.name || ''}
</p>
</div>
</button>
{/each}
</div>
{:else if query.length > 0}
<p class="text-sm text-[hsl(var(--muted-foreground))]">Mindestens 2 Zeichen eingeben...</p>
{/if}
</div>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsDangerZone,
SettingsDangerButton,
GlobalSettingsSection,
} from '@manacore/shared-ui';
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
</script>
<svelte:head>
<title>Einstellungen | Inventar</title>
</svelte:head>
<SettingsPage title="Einstellungen" subtitle="Verwalte dein Konto und passe die App an.">
<SettingsSection title="Konto">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsRow
label="E-Mail"
description={authStore.user?.email || 'Nicht angemeldet'}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{/snippet}
</SettingsRow>
</SettingsCard>
</SettingsSection>
<GlobalSettingsSection
{userSettings}
appId="inventar"
navItems={[
{ href: '/', label: 'Sammlungen', icon: 'archive' },
{ href: '/items', label: 'Alle Items', icon: 'list' },
{ href: '/locations', label: 'Standorte', icon: 'map-pin' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
]}
alwaysVisibleHrefs={['/', '/settings']}
/>
<SettingsSection title="Über">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsRow label="Version" border={false}>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
{/snippet}
<span class="text-[hsl(var(--muted-foreground))]">{APP_VERSION}</span>
</SettingsRow>
</SettingsCard>
</SettingsSection>
<SettingsDangerZone title="Gefahrenzone">
<SettingsDangerButton
label="Abmelden"
description="Von deinem Konto abmelden"
buttonText="Abmelden"
onclick={handleLogout}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{/snippet}
</SettingsDangerButton>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { ThemePage } from '@manacore/shared-theme';
import { theme } from '$lib/stores/theme';
</script>
<ThemePage {theme} appName="Inventar" />

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,263 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { getPillAppItems } from '@manacore/shared-branding';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
let showRegister = $state(false);
let showForgotPassword = $state(false);
let show2FA = $state(false);
let twoFactorCode = $state('');
let verificationSent = $state(false);
async function handleLogin() {
if (!email || !password) return;
isLoading = true;
error = '';
try {
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/');
} else if (result.error?.includes('two-factor') || result.error?.includes('2FA')) {
show2FA = true;
} else {
error = result.error || 'Login fehlgeschlagen';
}
} catch {
error = 'Ein Fehler ist aufgetreten';
} finally {
isLoading = false;
}
}
async function handleRegister() {
if (!email || !password) return;
isLoading = true;
error = '';
try {
const result = await authStore.signUp(email, password);
if (result.success && !result.needsVerification) {
goto('/');
} else if (result.needsVerification) {
verificationSent = true;
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
} catch {
error = 'Ein Fehler ist aufgetreten';
} finally {
isLoading = false;
}
}
async function handleForgotPassword() {
if (!email) {
error = 'Bitte E-Mail eingeben';
return;
}
isLoading = true;
error = '';
const result = await authStore.resetPassword(email);
if (result.success) {
verificationSent = true;
} else {
error = result.error || 'Fehler beim Zurücksetzen';
}
isLoading = false;
}
async function handle2FA() {
if (!twoFactorCode) return;
isLoading = true;
const result = await authStore.verifyTwoFactor(twoFactorCode, true);
if (result.success) {
goto('/');
} else {
error = 'Ungültiger Code';
}
isLoading = false;
}
async function handlePasskey() {
isLoading = true;
error = '';
const result = await authStore.signInWithPasskey();
if (result.success) {
goto('/');
} else {
error = result.error || 'Passkey fehlgeschlagen';
}
isLoading = false;
}
</script>
<svelte:head>
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Inventar</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="mb-8 text-center">
<div
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary))]"
>
<span class="text-3xl">📦</span>
</div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Inventar</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Inventarverwaltung</p>
</div>
{#if verificationSent}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 text-center"
>
<p class="text-[hsl(var(--foreground))]">
{showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'}
</p>
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">Bitte prüfe dein Postfach.</p>
<button
onclick={() => {
verificationSent = false;
showForgotPassword = false;
showRegister = false;
}}
class="mt-4 text-sm text-[hsl(var(--primary))]"
>
Zurück zum Login
</button>
</div>
{:else if show2FA}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
Zwei-Faktor-Authentifizierung
</h2>
<input
type="text"
bind:value={twoFactorCode}
placeholder="Code eingeben"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
onkeydown={(e) => e.key === 'Enter' && handle2FA()}
/>
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
<button
onclick={handle2FA}
disabled={isLoading}
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{isLoading ? 'Prüfe...' : 'Bestätigen'}
</button>
</div>
{:else}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
{showForgotPassword
? 'Passwort zurücksetzen'
: showRegister
? $_('auth.register')
: $_('auth.login')}
</h2>
<div class="space-y-3">
<input
type="email"
bind:value={email}
placeholder={$_('auth.email')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
/>
{#if !showForgotPassword}
<input
type="password"
bind:value={password}
placeholder={$_('auth.password')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
onkeydown={(e) =>
e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())}
/>
{/if}
</div>
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
<button
onclick={showForgotPassword
? handleForgotPassword
: showRegister
? handleRegister
: handleLogin}
disabled={isLoading}
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{isLoading
? $_('common.loading')
: showForgotPassword
? 'Link senden'
: showRegister
? $_('auth.register')
: $_('auth.login')}
</button>
{#if !showForgotPassword && authStore.isPasskeyAvailable()}
<button
onclick={handlePasskey}
disabled={isLoading}
class="mt-2 w-full rounded-lg border border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--foreground))]"
>
🔑 Mit Passkey anmelden
</button>
{/if}
<div class="mt-4 flex justify-between text-sm">
{#if showForgotPassword}
<button
onclick={() => {
showForgotPassword = false;
error = '';
}}
class="text-[hsl(var(--primary))]"
>
Zurück zum Login
</button>
{:else}
<button
onclick={() => {
showForgotPassword = true;
error = '';
}}
class="text-[hsl(var(--muted-foreground))]"
>
{$_('auth.forgotPassword')}
</button>
<button
onclick={() => {
showRegister = !showRegister;
error = '';
}}
class="text-[hsl(var(--primary))]"
>
{showRegister ? $_('auth.login') : $_('auth.register')}
</button>
{/if}
</div>
</div>
{/if}
<!-- App switcher -->
<div class="mt-6 flex flex-wrap justify-center gap-2">
{#each getPillAppItems() as app}
{#if app.id !== 'inventar'}
<a
href={app.url}
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
>
{app.label}
</a>
{/if}
{/each}
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="mb-4 text-6xl font-bold text-[hsl(var(--primary))]">{$page.status}</h1>
<p class="mb-8 text-lg text-[hsl(var(--muted-foreground))]">
{$page.error?.message || $_('error.notFound')}
</p>
<a
href="/"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
{$_('error.backToHome')}
</a>
</div>
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import '../app.css';
import '$lib/i18n';
import { waitLocale } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
let ready = $state(false);
onMount(async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
ready = true;
});
</script>
{#if ready}
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
{@render children()}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))]">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
></div>
<p class="text-sm text-[hsl(var(--muted-foreground))]">Laden...</p>
</div>
</div>
{/if}

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'inventar-web',
});
};

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Offline | Inventar</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
<div class="text-center">
<div
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-[hsl(var(--muted))]"
>
<span class="text-4xl">📦</span>
</div>
<h1 class="mb-2 text-2xl font-bold text-[hsl(var(--foreground))]">Offline</h1>
<p class="mb-6 text-[hsl(var(--muted-foreground))]">
Du bist gerade nicht mit dem Internet verbunden.
</p>
<button
onclick={() => window.location.reload()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
Erneut versuchen
</button>
</div>
</div>

View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#f59e0b"/>
<rect x="8" y="8" width="16" height="16" rx="2" stroke="white" stroke-width="1.5" fill="none"/>
<path d="M8 12h16" stroke="white" stroke-width="1"/>
<path d="M10 15h12M10 18h12M10 21h8" stroke="white" stroke-width="1" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" fill="none">
<rect width="96" height="96" rx="20" fill="#f59e0b"/>
<path d="M28 38h40v4H28zm0 8h40v4H28zm0 8h30v4H28z" fill="white" opacity="0.9"/>
<rect x="24" y="24" width="48" height="48" rx="6" stroke="white" stroke-width="3" fill="none"/>
<path d="M24 36h48" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View file

@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,64 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vite';
import { getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'Inventar',
short_name: 'Inventar',
description: 'Konfigurierbare Inventarverwaltung',
theme_color: '#f59e0b',
background_color: '#0f172a',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
shortcuts: [
{
name: 'Neues Item',
short_name: 'Neues Item',
url: '/items?action=new',
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
},
{
name: 'Sammlungen',
short_name: 'Sammlungen',
url: '/collections',
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
},
],
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
},
}),
],
server: {
port: 5190,
strictPort: true,
},
preview: {
port: 5190,
},
define: getBuildDefines(),
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
},
});

View file

@ -0,0 +1,14 @@
{
"name": "inventar",
"version": "1.0.0",
"private": true,
"description": "Inventar - Configurable Inventory Management",
"scripts": {
"dev": "pnpm --filter @inventar/web dev",
"dev:web": "pnpm --filter @inventar/web dev"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -0,0 +1,22 @@
{
"name": "@inventar/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./constants": "./src/constants/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,199 @@
import type { Template, ItemStatus } from '../types/index.js';
export const ITEM_STATUSES: {
value: ItemStatus;
labelDe: string;
labelEn: string;
color: string;
}[] = [
{ value: 'owned', labelDe: 'Besitzt', labelEn: 'Owned', color: '#22c55e' },
{ value: 'lent', labelDe: 'Verliehen', labelEn: 'Lent', color: '#f59e0b' },
{ value: 'stored', labelDe: 'Eingelagert', labelEn: 'Stored', color: '#3b82f6' },
{ value: 'for_sale', labelDe: 'Zu verkaufen', labelEn: 'For Sale', color: '#a855f7' },
{ value: 'disposed', labelDe: 'Entsorgt', labelEn: 'Disposed', color: '#6b7280' },
];
export const DEFAULT_TEMPLATES: Template[] = [
{
id: 'electronics',
name: 'Elektronik',
description: 'Computer, Smartphones, Gadgets',
icon: '💻',
category: 'tech',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{ id: 'serial_number', name: 'Seriennummer', type: 'text', order: 2 },
{ id: 'purchase_date', name: 'Kaufdatum', type: 'date', order: 3 },
{ id: 'warranty_until', name: 'Garantie bis', type: 'date', order: 4 },
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Defekt'],
order: 6,
},
],
},
},
{
id: 'books',
name: 'Bücher',
description: 'Bücher, E-Books, Hörbücher',
icon: '📚',
category: 'media',
schema: {
fields: [
{ id: 'author', name: 'Autor', type: 'text', order: 0 },
{ id: 'isbn', name: 'ISBN', type: 'text', order: 1 },
{ id: 'publisher', name: 'Verlag', type: 'text', order: 2 },
{ id: 'genre', name: 'Genre', type: 'text', order: 3 },
{ id: 'pages', name: 'Seiten', type: 'number', order: 4 },
{ id: 'read', name: 'Gelesen', type: 'checkbox', order: 5 },
{
id: 'rating',
name: 'Bewertung',
type: 'select',
options: ['1', '2', '3', '4', '5'],
order: 6,
},
],
},
},
{
id: 'furniture',
name: 'Möbel',
description: 'Tische, Stühle, Regale',
icon: '🪑',
category: 'home',
schema: {
fields: [
{ id: 'material', name: 'Material', type: 'text', order: 0 },
{ id: 'dimensions', name: 'Maße', type: 'text', placeholder: 'B x H x T in cm', order: 1 },
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
{ id: 'room', name: 'Raum', type: 'text', order: 3 },
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Reparaturbedürftig'],
order: 4,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
],
},
},
{
id: 'clothing',
name: 'Kleidung',
description: 'Kleidung, Schuhe, Accessoires',
icon: '👕',
category: 'fashion',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'size', name: 'Größe', type: 'text', order: 1 },
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
{ id: 'material', name: 'Material', type: 'text', order: 3 },
{
id: 'season',
name: 'Saison',
type: 'select',
options: ['Frühling', 'Sommer', 'Herbst', 'Winter', 'Ganzjährig'],
order: 4,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
],
},
},
{
id: 'tools',
name: 'Werkzeug',
description: 'Handwerkzeug, Elektrowerkzeug',
icon: '🔧',
category: 'home',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{
id: 'type',
name: 'Typ',
type: 'select',
options: ['Handwerkzeug', 'Elektrowerkzeug', 'Messwerkzeug', 'Sonstiges'],
order: 2,
},
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Gut', 'Gebraucht', 'Defekt'],
order: 3,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
],
},
},
{
id: 'kitchen',
name: 'Küche',
description: 'Küchengeräte, Geschirr, Besteck',
icon: '🍳',
category: 'home',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'material', name: 'Material', type: 'text', order: 1 },
{
id: 'category',
name: 'Kategorie',
type: 'select',
options: ['Gerät', 'Geschirr', 'Besteck', 'Topf/Pfanne', 'Sonstiges'],
order: 2,
},
{ id: 'dishwasher_safe', name: 'Spülmaschinenfest', type: 'checkbox', order: 3 },
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
],
},
},
{
id: 'media',
name: 'Medien',
description: 'Filme, Musik, Spiele',
icon: '🎬',
category: 'media',
schema: {
fields: [
{
id: 'format',
name: 'Format',
type: 'select',
options: ['DVD', 'Blu-ray', 'CD', 'Vinyl', 'Digital', 'Kassette'],
order: 0,
},
{ id: 'artist', name: 'Künstler/Regisseur', type: 'text', order: 1 },
{ id: 'genre', name: 'Genre', type: 'text', order: 2 },
{ id: 'year', name: 'Erscheinungsjahr', type: 'number', order: 3 },
{
id: 'rating',
name: 'Bewertung',
type: 'select',
options: ['1', '2', '3', '4', '5'],
order: 4,
},
],
},
},
{
id: 'custom',
name: 'Benutzerdefiniert',
description: 'Leere Sammlung, eigene Felder definieren',
icon: '✨',
category: 'other',
schema: {
fields: [],
},
},
];

View file

@ -0,0 +1,2 @@
export * from './types/index.js';
export * from './constants/index.js';

View file

@ -0,0 +1,171 @@
// Field types for configurable schemas
export type FieldType =
| 'text'
| 'number'
| 'date'
| 'select'
| 'tags'
| 'checkbox'
| 'url'
| 'currency';
// Custom field definition (stored in collection schema)
export interface FieldDefinition {
id: string;
name: string;
type: FieldType;
required?: boolean;
defaultValue?: unknown;
options?: string[]; // for select fields
currencyCode?: string; // for currency fields
placeholder?: string;
order: number;
}
// Collection schema
export interface CollectionSchema {
fields: FieldDefinition[];
}
// Item status
export type ItemStatus = 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed';
// Purchase data
export interface PurchaseData {
price?: number;
currency?: string;
date?: string;
retailer?: string;
warrantyExpiry?: string;
receiptUrl?: string;
}
// Item note
export interface ItemNote {
id: string;
content: string;
createdAt: string;
updatedAt: string;
}
// Photo
export interface ItemPhoto {
id: string;
url: string;
thumbnailUrl?: string;
caption?: string;
order: number;
}
// Document attachment
export interface ItemDocument {
id: string;
name: string;
url: string;
mimeType: string;
size: number;
uploadedAt: string;
}
// Collection
export interface Collection {
id: string;
name: string;
description?: string;
icon?: string;
color?: string;
schema: CollectionSchema;
templateId?: string;
order: number;
itemCount?: number;
createdAt: string;
updatedAt: string;
}
// Location (hierarchical)
export interface Location {
id: string;
parentId?: string;
name: string;
description?: string;
icon?: string;
path: string;
depth: number;
order: number;
children?: Location[];
createdAt: string;
updatedAt: string;
}
// Category
export interface Category {
id: string;
parentId?: string;
name: string;
icon?: string;
color?: string;
order: number;
children?: Category[];
createdAt: string;
updatedAt: string;
}
// Item
export interface Item {
id: string;
collectionId: string;
locationId?: string;
categoryId?: string;
name: string;
description?: string;
status: ItemStatus;
quantity: number;
fieldValues: Record<string, unknown>;
purchaseData?: PurchaseData;
photos: ItemPhoto[];
notes: ItemNote[];
documents: ItemDocument[];
tags: string[];
order: number;
createdAt: string;
updatedAt: string;
}
// Template definition
export interface Template {
id: string;
name: string;
description: string;
icon: string;
schema: CollectionSchema;
category: string;
}
// Saved filter
export interface SavedFilter {
id: string;
name: string;
criteria: FilterCriteria;
createdAt: string;
}
export interface FilterCriteria {
search?: string;
status?: ItemStatus[];
locationId?: string;
categoryId?: string;
tagIds?: string[];
collectionId?: string;
}
// View mode
export type ViewMode = 'list' | 'grid' | 'table';
// Sort options
export type SortField = 'name' | 'createdAt' | 'updatedAt' | 'status' | 'quantity';
export type SortDirection = 'asc' | 'desc';
export interface SortOption {
field: SortField;
direction: SortDirection;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,120 @@
---
title: 'Inventar: Production Readiness Audit'
description: 'Konfigurierbare Inventarverwaltung mit Schema-Editor, 8 Feldtypen, 8 Vorlagen, 3 Ansichten, hierarchischen Standorten - aktuell als localStorage-Prototype ohne Backend'
date: 2026-03-27
app: 'inventar'
author: 'Claude Code'
tags: ['audit', 'inventar', 'production-readiness', 'prototype']
score: 28
scores:
backend: 0
frontend: 55
database: 0
testing: 0
deployment: 5
documentation: 50
security: 30
ux: 60
status: 'alpha'
version: '1.0.0'
stats:
backendModules: 0
webRoutes: 15
components: 10
dbTables: 0
testFiles: 0
testCount: 0
languages: 2
linesOfCode: 4500
sourceFiles: 45
sizeInMb: 0.2
commits: 0
contributors: 2
firstCommitDate: '2026-03-27'
todoCount: 0
apiEndpoints: 0
stores: 6
maxFileLines: 250
---
## Zusammenfassung
Inventar ist eine **neue, schemalose Inventarverwaltung** mit konfigurierbaren Sammlungen und Feldern. Aktuell als reiner SvelteKit-Prototype mit localStorage-Persistenz. Das Datenmodell (Types, Templates, Stores) steht vollständig, aber ohne Backend, Datenbank und Tests ist die App weit von Production entfernt.
## Backend (0/100)
- Kein Backend vorhanden
- Kein NestJS-Service
- Kein API, keine Endpoints
- Daten nur in localStorage
- **Nächster Schritt:** NestJS Backend mit Drizzle ORM, JSONB für flexible Schemas
## Frontend (55/100)
- SvelteKit 2 + Svelte 5 Runes
- Tailwind CSS 4 mit shared-tailwind Theme
- 15 Routes: Collections, Items, Locations, Categories, Search, Settings, Profile, etc.
- 10 Komponenten: FieldRenderer, FieldEditor, SchemaEditor, StatusBadge, ViewModeToggle
- 6 Svelte 5 Rune Stores mit localStorage-Persistenz
- i18n mit svelte-i18n (DE + EN)
- PWA-Manifest konfiguriert
- Security Headers (CSP, X-Frame-Options) in hooks.server.ts
- Error Tracking (GlitchTip) vorbereitet
- **Lücke:** Keine PWA-Icons, keine Skeleton-Loader, keine Offline-Page, kein Error Boundary
## Database (0/100)
- Keine Datenbank
- Kein Drizzle Schema
- Kein Seed Script
- Shared Types definieren das Datenmodell (JSONB-ready)
- **Nächster Schritt:** PostgreSQL mit JSONB für fieldValues, GIN-Index für Suche
## Testing (0/100)
- Keine Unit Tests
- Keine E2E Tests
- Keine Mock Factories
- Vitest konfiguriert aber leer
- **Nächster Schritt:** Store-Tests, Component-Tests, E2E für Collection/Item CRUD
## Security (30/100)
- Auth-Code vorhanden (Mana Core Auth)
- SSO-Integration implementiert (aber nicht registriert)
- CSP Headers gesetzt
- X-Frame-Options: DENY
- **Lücke:** Nicht in trustedOrigins, kein Rate Limiting, keine Input-Validierung (kein Backend)
## Deployment (5/100)
- Health Check Endpoint vorhanden (`/health`)
- Kein Dockerfile
- Nicht in docker-compose
- Nicht deployed
- **Nächster Schritt:** Dockerfile, docker-compose Entry, Traefik Labels
## Documentation (50/100)
- CLAUDE.md mit Projektübersicht
- Shared Types vollständig dokumentiert
- package.json Scripts vorhanden
- **Lücke:** Keine API-Docs (kein Backend), kein Env-Vars Guide
## UX (60/100)
- 3 Ansichten (Liste, Kacheln, Tabelle)
- Responsive Design (Mobile + Desktop)
- Status-Badges farblich kodiert
- Schema-Editor mit Drag-Reorder (up/down)
- Template-Selektor mit 8 Vorlagen
- Hierarchischer Standort-Baum
- Volltextsuche über alle Felder
- Dark/Light Mode via shared-theme
- **Lücke:** Keine Keyboard Shortcuts, keine Animationen/Transitions, keine Toast-Benachrichtigungen
## Top-3 Empfehlungen
1. **NestJS Backend** - PostgreSQL + Drizzle mit JSONB-Schema für flexible Felder
2. **Tests schreiben** - Mindestens Store-Tests und E2E für CRUD-Flows
3. **Docker + Deploy** - Dockerfile erstellen, in docker-compose aufnehmen, auf mana.how deployen