diff --git a/apps/inventar/CLAUDE.md b/apps/inventar/CLAUDE.md
new file mode 100644
index 000000000..330a5ef90
--- /dev/null
+++ b/apps/inventar/CLAUDE.md
@@ -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
+```
diff --git a/apps/inventar/apps/web/Dockerfile b/apps/inventar/apps/web/Dockerfile
new file mode 100644
index 000000000..f9489a2bc
--- /dev/null
+++ b/apps/inventar/apps/web/Dockerfile
@@ -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"]
diff --git a/apps/inventar/apps/web/package.json b/apps/inventar/apps/web/package.json
new file mode 100644
index 000000000..7e6c85504
--- /dev/null
+++ b/apps/inventar/apps/web/package.json
@@ -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"
+}
diff --git a/apps/inventar/apps/web/src/app.css b/apps/inventar/apps/web/src/app.css
new file mode 100644
index 000000000..9a544e6fb
--- /dev/null
+++ b/apps/inventar/apps/web/src/app.css
@@ -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);
+}
diff --git a/apps/inventar/apps/web/src/app.d.ts b/apps/inventar/apps/web/src/app.d.ts
new file mode 100644
index 000000000..8e8e79928
--- /dev/null
+++ b/apps/inventar/apps/web/src/app.d.ts
@@ -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 {};
diff --git a/apps/inventar/apps/web/src/app.html b/apps/inventar/apps/web/src/app.html
new file mode 100644
index 000000000..457203c27
--- /dev/null
+++ b/apps/inventar/apps/web/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ Inventar
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/inventar/apps/web/src/hooks.client.ts b/apps/inventar/apps/web/src/hooks.client.ts
new file mode 100644
index 000000000..613945c81
--- /dev/null
+++ b/apps/inventar/apps/web/src/hooks.client.ts
@@ -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 });
+};
diff --git a/apps/inventar/apps/web/src/hooks.server.ts b/apps/inventar/apps/web/src/hooks.server.ts
new file mode 100644
index 000000000..67f71c292
--- /dev/null
+++ b/apps/inventar/apps/web/src/hooks.server.ts
@@ -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(
+ '',
+ ``
+ );
+ },
+ });
+
+ // 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);
diff --git a/apps/inventar/apps/web/src/lib/components/StatusBadge.svelte b/apps/inventar/apps/web/src/lib/components/StatusBadge.svelte
new file mode 100644
index 000000000..aa38c1f7c
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/StatusBadge.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {$_(`status.${status}`)}
+
diff --git a/apps/inventar/apps/web/src/lib/components/ViewModeToggle.svelte b/apps/inventar/apps/web/src/lib/components/ViewModeToggle.svelte
new file mode 100644
index 000000000..b827584bf
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/ViewModeToggle.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {#each modes as mode}
+
+ {/each}
+
diff --git a/apps/inventar/apps/web/src/lib/components/fields/FieldEditor.svelte b/apps/inventar/apps/web/src/lib/components/fields/FieldEditor.svelte
new file mode 100644
index 000000000..cd14ca0ab
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/fields/FieldEditor.svelte
@@ -0,0 +1,127 @@
+
+
+{#if field.type === 'text'}
+
+{:else if field.type === 'number'}
+
+{:else if field.type === 'currency'}
+
+
+
+ {field.currencyCode || 'EUR'}
+
+
+{:else if field.type === 'date'}
+
+{:else if field.type === 'checkbox'}
+
+{:else if field.type === 'select'}
+
+{:else if field.type === 'url'}
+
+{:else if field.type === 'tags'}
+ {@const currentTags = Array.isArray(value) ? (value as string[]) : []}
+
+
+ {#each currentTags as tag, i}
+
+ {tag}
+
+
+ {/each}
+
+
{
+ 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 = '';
+ }
+ }
+ }}
+ />
+
+{/if}
diff --git a/apps/inventar/apps/web/src/lib/components/fields/FieldRenderer.svelte b/apps/inventar/apps/web/src/lib/components/fields/FieldRenderer.svelte
new file mode 100644
index 000000000..897ae25eb
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/fields/FieldRenderer.svelte
@@ -0,0 +1,70 @@
+
+
+{#if value === undefined || value === null || value === ''}
+ —
+{:else if field.type === 'checkbox'}
+ {#if value}
+ ✓
+ {:else}
+ ✗
+ {/if}
+{:else if field.type === 'currency'}
+ {formatCurrency(value, field.currencyCode)}
+{:else if field.type === 'date'}
+ {formatDate(value)}
+{:else if field.type === 'url'}
+
+ {String(value)
+ .replace(/^https?:\/\//, '')
+ .slice(0, 40)}
+
+{:else if field.type === 'select'}
+
+ {String(value)}
+
+{:else if field.type === 'tags'}
+
+ {#each Array.isArray(value) ? value : [] as tag}
+ {tag}
+ {/each}
+
+{:else if field.type === 'number'}
+ {Number(value).toLocaleString('de-DE')}
+{:else}
+ {String(value)}
+{/if}
diff --git a/apps/inventar/apps/web/src/lib/components/fields/SchemaEditor.svelte b/apps/inventar/apps/web/src/lib/components/fields/SchemaEditor.svelte
new file mode 100644
index 000000000..e1c67484c
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/fields/SchemaEditor.svelte
@@ -0,0 +1,225 @@
+
+
+
+ {#each fields.sort((a, b) => a.order - b.order) as field, index (field.id)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
diff --git a/apps/inventar/apps/web/src/lib/components/skeletons/CollectionListSkeleton.svelte b/apps/inventar/apps/web/src/lib/components/skeletons/CollectionListSkeleton.svelte
new file mode 100644
index 000000000..88e1e3c1a
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/skeletons/CollectionListSkeleton.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#each Array(count) as _}
+
+ {/each}
+
diff --git a/apps/inventar/apps/web/src/lib/components/skeletons/ItemListSkeleton.svelte b/apps/inventar/apps/web/src/lib/components/skeletons/ItemListSkeleton.svelte
new file mode 100644
index 000000000..9cff45b99
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/skeletons/ItemListSkeleton.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {#each Array(count) as _}
+
+ {/each}
+
diff --git a/apps/inventar/apps/web/src/lib/components/skeletons/index.ts b/apps/inventar/apps/web/src/lib/components/skeletons/index.ts
new file mode 100644
index 000000000..558d3ad37
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/components/skeletons/index.ts
@@ -0,0 +1,2 @@
+export { default as CollectionListSkeleton } from './CollectionListSkeleton.svelte';
+export { default as ItemListSkeleton } from './ItemListSkeleton.svelte';
diff --git a/apps/inventar/apps/web/src/lib/i18n/index.ts b/apps/inventar/apps/web/src/lib/i18n/index.ts
new file mode 100644
index 000000000..080e6330b
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/i18n/index.ts
@@ -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 };
diff --git a/apps/inventar/apps/web/src/lib/i18n/locales/de.json b/apps/inventar/apps/web/src/lib/i18n/locales/de.json
new file mode 100644
index 000000000..5a7d8bc1f
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/i18n/locales/de.json
@@ -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"
+ }
+}
diff --git a/apps/inventar/apps/web/src/lib/i18n/locales/en.json b/apps/inventar/apps/web/src/lib/i18n/locales/en.json
new file mode 100644
index 000000000..31da651f8
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/i18n/locales/en.json
@@ -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"
+ }
+}
diff --git a/apps/inventar/apps/web/src/lib/services/feedback.ts b/apps/inventar/apps/web/src/lib/services/feedback.ts
new file mode 100644
index 000000000..63b7beade
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/services/feedback.ts
@@ -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(),
+});
diff --git a/apps/inventar/apps/web/src/lib/stores/auth.svelte.ts b/apps/inventar/apps/web/src/lib/stores/auth.svelte.ts
new file mode 100644
index 000000000..de1ecb6d1
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/auth.svelte.ts
@@ -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['authService'] | null = null;
+let _tokenManager: ReturnType['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(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 {
+ 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' };
+ }
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts b/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts
new file mode 100644
index 000000000..974dd4ff4
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts
@@ -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([]);
+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>) {
+ categories = categories.map((c) =>
+ c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
+ );
+ saveToStorage(categories);
+ },
+
+ delete(id: string) {
+ const idsToDelete = new Set();
+ 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);
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts b/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts
new file mode 100644
index 000000000..b4d4544f8
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts
@@ -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([]);
+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>
+ ) {
+ 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);
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/stores/items.svelte.ts b/apps/inventar/apps/web/src/lib/stores/items.svelte.ts
new file mode 100644
index 000000000..eda2f1327
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/items.svelte.ts
@@ -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- ([]);
+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;
+ 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) {
+ 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;
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts b/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts
new file mode 100644
index 000000000..a54db3084
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts
@@ -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([]);
+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>) {
+ 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();
+ 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);
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/stores/navigation.ts b/apps/inventar/apps/web/src/lib/stores/navigation.ts
new file mode 100644
index 000000000..a0dd7b724
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/navigation.ts
@@ -0,0 +1,6 @@
+import { createSimpleNavigationStores } from '@manacore/shared-stores';
+
+export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({
+ withToolbar: true,
+ toolbarCollapsedDefault: true,
+});
diff --git a/apps/inventar/apps/web/src/lib/stores/theme.ts b/apps/inventar/apps/web/src/lib/stores/theme.ts
new file mode 100644
index 000000000..5f3324f9c
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/theme.ts
@@ -0,0 +1,6 @@
+import { createThemeStore } from '@manacore/shared-theme';
+
+export const theme = createThemeStore({
+ appId: 'inventar',
+ defaultVariant: 'ocean',
+});
diff --git a/apps/inventar/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/inventar/apps/web/src/lib/stores/user-settings.svelte.ts
new file mode 100644
index 000000000..dbec1efc3
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/user-settings.svelte.ts
@@ -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(),
+});
diff --git a/apps/inventar/apps/web/src/lib/stores/view.svelte.ts b/apps/inventar/apps/web/src/lib/stores/view.svelte.ts
new file mode 100644
index 000000000..f14aa6b39
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/stores/view.svelte.ts
@@ -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(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('list');
+let sort = $state({ field: 'name', direction: 'asc' });
+let activeFilters = $state({});
+let savedFilters = $state([]);
+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(VIEW_KEY, 'list');
+ sort = load(SORT_KEY, { field: 'name', direction: 'asc' });
+ savedFilters = load(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(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);
+ },
+};
diff --git a/apps/inventar/apps/web/src/lib/version.ts b/apps/inventar/apps/web/src/lib/version.ts
new file mode 100644
index 000000000..4f221f09f
--- /dev/null
+++ b/apps/inventar/apps/web/src/lib/version.ts
@@ -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';
diff --git a/apps/inventar/apps/web/src/routes/(app)/+layout.svelte b/apps/inventar/apps/web/src/routes/(app)/+layout.svelte
new file mode 100644
index 000000000..ebd5ca537
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/+layout.svelte
@@ -0,0 +1,173 @@
+
+
+{#if !authStore.initialized}
+
+{:else if !authStore.isAuthenticated}
+
+{:else}
+
+
+ {#if showNav}
+
+ {/if}
+
+
+
+ {@render children()}
+
+
+
+
+
+{/if}
diff --git a/apps/inventar/apps/web/src/routes/(app)/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/+page.svelte
new file mode 100644
index 000000000..250c9ead6
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/+page.svelte
@@ -0,0 +1,133 @@
+
+
+
+ Inventar
+
+
+
+
+
+
+
+ {#if collectionsStore.collections.length === 0}
+
+ {:else}
+
+ {#each collectionsStore.collections.sort((a, b) => a.order - b.order) as collection (collection.id)}
+
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)]"
+ >
+
+
+
{collection.icon || '📁'}
+
+
{collection.name}
+ {#if collection.description}
+
+ {collection.description}
+
+ {/if}
+
+
+
+
+
+
+
+ {getItemCount(collection.id)} Items
+
+
+ {#each collection.schema.fields.slice(0, 3) as field}
+
+ {field.name}
+
+ {/each}
+ {#if collection.schema.fields.length > 3}
+
+ +{collection.schema.fields.length - 3}
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/categories/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/categories/+page.svelte
new file mode 100644
index 000000000..2c1a4da29
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/categories/+page.svelte
@@ -0,0 +1,143 @@
+
+
+
+ {$_('nav.categories')} | Inventar
+
+
+
+
+
{$_('nav.categories')}
+
+
+
+ {#if showForm}
+
+ {/if}
+
+ {#if categoriesStore.categories.length === 0}
+
+
🏷️
+
{$_('category.noCategories')}
+
+ {:else}
+
+ {#each categoriesStore.categories.sort((a, b) => a.order - b.order) as category (category.id)}
+
+ {category.icon || '🏷️'}
+ {#if category.color}
+
+ {/if}
+ {category.name}
+
+
+
+ {/each}
+
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte
new file mode 100644
index 000000000..0fa022406
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte
@@ -0,0 +1,311 @@
+
+
+
+ {collection?.name || 'Sammlung'} | Inventar
+
+
+{#if !collection}
+
+
Sammlung nicht gefunden
+
Zurück
+
+{:else}
+
+
+
+
+
+
+
+
{collection.icon || '📁'}
+
+
{collection.name}
+ {#if collection.description}
+
{collection.description}
+ {/if}
+
+
+
+
viewStore.setViewMode(m)} />
+
+
+
+
+
+
+
+
+ {#if showNewItem}
+
+
e.key === 'Enter' && createItem()}
+ />
+
+
+ {#if collection.schema.fields.length > 0}
+
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
+
+
+ (newItemFields = { ...newItemFields, [field.id]: v })}
+ />
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+ {/if}
+
+
+ {#if sortedItems.length === 0}
+
+
📭
+
{$_('item.noItems')}
+
+
+ {:else if viewStore.viewMode === 'grid'}
+
+
+ {#each sortedItems as item (item.id)}
+
+ {/each}
+
+ {:else if viewStore.viewMode === 'table'}
+
+
+
+
+
+ | {$_('item.name')} |
+ {$_('item.status')} |
+ {#each collection.schema.fields as field}
+ {field.name} |
+ {/each}
+ |
+
+
+
+ {#each sortedItems as item (item.id)}
+ goto(`/items/${item.id}`)}
+ >
+ | {item.name} |
+ |
+ {#each collection.schema.fields as field}
+ |
+ {/each}
+
+ deleteItem(e, item.id)}
+ class="text-[hsl(var(--muted-foreground))] hover:text-red-500"
+ >
+
+
+ |
+
+ {/each}
+
+
+
+ {:else}
+
+
+ {#each sortedItems as item (item.id)}
+
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)]"
+ >
+
+
+
{item.name}
+
+
+ {#if collection.schema.fields.length > 0}
+
+ {#each collection.schema.fields.slice(0, 4) as field}
+ {#if item.fieldValues[field.id] !== undefined}
+ {field.name}:
+ {/if}
+ {/each}
+
+ {/if}
+
+ {#if item.quantity > 1}
+ ×{item.quantity}
+ {/if}
+ deleteItem(e, item.id)}
+ class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
+ >
+
+
+
+ {/each}
+
+ {/if}
+
+{/if}
diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte
new file mode 100644
index 000000000..d594f806f
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte
@@ -0,0 +1,116 @@
+
+
+
+ {$_('collection.edit')} | Inventar
+
+
+{#if !collection}
+ Sammlung nicht gefunden
+{:else}
+
+
+
goto(`/collections/${collection.id}`)}
+ class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
+ >
+
+
+
{$_('collection.edit')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {$_('collection.customFields')}
+
+ (schema = { fields })} />
+
+
+
+ goto(`/collections/${collection.id}`)}
+ class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
+ >
+ {$_('common.cancel')}
+
+
+ {$_('common.save')}
+
+
+
+
+{/if}
diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte
new file mode 100644
index 000000000..3fb424a55
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte
@@ -0,0 +1,147 @@
+
+
+
+ {$_('collection.create')} | Inventar
+
+
+
+
+
(step === 'details' && selectedTemplate ? (step = 'template') : goto('/'))}
+ class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
+ >
+
+
+
{$_('collection.create')}
+
+
+ {#if step === 'template'}
+
+
+
+ {$_('collection.selectTemplate')}
+
+
+ {#each DEFAULT_TEMPLATES as template}
+
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)]"
+ >
+
+
{template.icon}
+
+
{template.name}
+
{template.description}
+
+
+ {#if template.schema.fields.length > 0}
+
+ {#each template.schema.fields as field}
+
+ {field.name}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+
+ {:else}
+
+
+
+
+
+
+
+
+
+ {$_('collection.customFields')}
+
+ (schema = { fields })} />
+
+
+
+
+ goto('/')}
+ class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
+ >
+ {$_('common.cancel')}
+
+
+ {$_('common.create')}
+
+
+
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/feedback/+page.svelte
new file mode 100644
index 000000000..079388766
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/feedback/+page.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/help/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/help/+page.svelte
new file mode 100644
index 000000000..8385515eb
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/help/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte
new file mode 100644
index 000000000..bc0c2ff7e
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte
@@ -0,0 +1,147 @@
+
+
+
+ {$_('nav.allItems')} | Inventar
+
+
+
+
+
{$_('nav.allItems')}
+ viewStore.setViewMode(m)} />
+
+
+
+
+
+
+
+
+ {#each statuses as status}
+ 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}`)}
+
+ {/each}
+ {#if viewStore.hasActiveFilters}
+ 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')}
+
+ {/if}
+
+
+
+
+
{sortedItems.length} Items
+
+ {#if sortedItems.length === 0}
+
+
🔍
+
{$_('common.noResults')}
+
+ {:else if viewStore.viewMode === 'grid'}
+
+ {#each sortedItems as item (item.id)}
+
goto(`/items/${item.id}`)}
+ class="item-card rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
+ >
+
+
{item.name}
+
+
+
+ {getCollectionName(item.collectionId)}
+
+
+ {/each}
+
+ {:else}
+
+ {#each sortedItems as item (item.id)}
+
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)]"
+ >
+
+
+
{item.name}
+
+
+
+ {getCollectionName(item.collectionId)}
+
+
+ {#if item.quantity > 1}
+ ×{item.quantity}
+ {/if}
+
+ {/each}
+
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte
new file mode 100644
index 000000000..397dc8ece
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte
@@ -0,0 +1,311 @@
+
+
+
+ {item?.name || 'Item'} | Inventar
+
+
+{#if !item}
+
+
Item nicht gefunden
+
Zurück
+
+{:else}
+
+
+
+
+
goto(collection ? `/collections/${collection.id}` : '/items')}
+ class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
+ >
+
+
+ {#if !editing}
+
+
{item.name}
+ {#if collection}
+
+ {collection.icon}
+ {collection.name}
+
+ {/if}
+
+ {/if}
+
+
+ {#if editing}
+ (editing = false)}
+ class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
+ >{$_('common.cancel')}
+ {$_('common.save')}
+ {:else}
+ {$_('common.edit')}
+ {$_('common.delete')}
+ {/if}
+
+
+
+ {#if editing}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if locationsStore.locations.length > 0}
+
+
+
+
+ {/if}
+ {#if categoriesStore.categories.length > 0}
+
+
+
+
+ {/if}
+
+
+ {#if collection}
+
+
+ {$_('collection.customFields')}
+
+
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
+
+
+ (editFields = { ...editFields, [field.id]: v })}
+ />
+
+ {/each}
+
+
+ {/if}
+
+ {:else}
+
+
+
+
+
+ {#if item.quantity > 1}
+ ×{item.quantity}
+ {/if}
+ {#if item.locationId}
+ {@const loc = locationsStore.getById(item.locationId)}
+ {#if loc}
+
+ 📍 {locationsStore.getFullPath(loc.id)}
+
+ {/if}
+ {/if}
+ {#if item.categoryId}
+ {@const cat = categoriesStore.getById(item.categoryId)}
+ {#if cat}
+ {cat.icon || '🏷️'} {cat.name}
+ {/if}
+ {/if}
+
+
+ {#if item.description}
+
{item.description}
+ {/if}
+
+
+ {#if collection && collection.schema.fields.length > 0}
+
+
Details
+
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
+
+ {field.name}:
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ {$_('item.notes')} ({item.notes.length})
+
+
+ {#each item.notes as note (note.id)}
+
+
+
{note.content}
+
+ {new Date(note.createdAt).toLocaleDateString('de-DE')}
+
+
+
itemsStore.deleteNote(item.id, note.id)}
+ class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
+ >×
+
+ {/each}
+
+
+ e.key === 'Enter' && addNote()}
+ />
+ +
+
+
+
+ {/if}
+
+{/if}
diff --git a/apps/inventar/apps/web/src/routes/(app)/locations/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/locations/+page.svelte
new file mode 100644
index 000000000..bb8042473
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/locations/+page.svelte
@@ -0,0 +1,153 @@
+
+
+
+ {$_('nav.locations')} | Inventar
+
+
+
+
+
{$_('nav.locations')}
+
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))]"
+ >
+
+ {$_('location.create')}
+
+
+
+ {#if showForm}
+
+ {/if}
+
+ {#if tree.length === 0}
+
+
📍
+
{$_('location.noLocations')}
+
+ {:else}
+
+ {#snippet renderTree(locations: Location[], depth: number)}
+ {#each locations as location (location.id)}
+
+
+ {location.icon || '📍'}
+ {location.name}
+ startCreate(location.id)}
+ class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
+ title={$_('location.addSub')}>+
+ startEdit(location)}
+ class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
+ >✎
+ deleteLocation(location.id)}
+ class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-red-500"
+ >×
+
+ {#if location.children?.length}
+ {@render renderTree(location.children, depth + 1)}
+ {/if}
+
+ {/each}
+ {/snippet}
+ {@render renderTree(tree, 0)}
+
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/mana/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/mana/+page.svelte
new file mode 100644
index 000000000..a78dae77c
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/mana/+page.svelte
@@ -0,0 +1,39 @@
+
+
+
+ Mana - Inventar
+
+
+
+
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/profile/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/profile/+page.svelte
new file mode 100644
index 000000000..a7f0939b9
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/profile/+page.svelte
@@ -0,0 +1,6 @@
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte
new file mode 100644
index 000000000..44fa10855
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte
@@ -0,0 +1,65 @@
+
+
+
+ {$_('nav.search')} | Inventar
+
+
+
+
{$_('nav.search')}
+
+
+
+ {#if query.length >= 2}
+
{results.length} Ergebnisse
+
+ {#each results as item (item.id)}
+
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)]"
+ >
+
+
+
{item.name}
+
+
+
+ {collectionsStore.getById(item.collectionId)?.name || ''}
+
+
+
+ {/each}
+
+ {:else if query.length > 0}
+
Mindestens 2 Zeichen eingeben...
+ {/if}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/settings/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/settings/+page.svelte
new file mode 100644
index 000000000..54a47b2b5
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/settings/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+ Einstellungen | Inventar
+
+
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+
+ {#snippet icon()}
+
+ {/snippet}
+ {APP_VERSION}
+
+
+
+
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+
+
+ v{APP_VERSION}
+
diff --git a/apps/inventar/apps/web/src/routes/(app)/themes/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/themes/+page.svelte
new file mode 100644
index 000000000..54f7e36fa
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(app)/themes/+page.svelte
@@ -0,0 +1,6 @@
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/(auth)/+layout.svelte b/apps/inventar/apps/web/src/routes/(auth)/+layout.svelte
new file mode 100644
index 000000000..a54cfdcb7
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,5 @@
+
+
+{@render children()}
diff --git a/apps/inventar/apps/web/src/routes/(auth)/login/+page.svelte b/apps/inventar/apps/web/src/routes/(auth)/login/+page.svelte
new file mode 100644
index 000000000..c9894c528
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/(auth)/login/+page.svelte
@@ -0,0 +1,263 @@
+
+
+
+ {showRegister ? $_('auth.register') : $_('auth.login')} | Inventar
+
+
+
+
+
+
+
+ 📦
+
+
Inventar
+
Inventarverwaltung
+
+
+ {#if verificationSent}
+
+
+ {showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'}
+
+
Bitte prüfe dein Postfach.
+
{
+ verificationSent = false;
+ showForgotPassword = false;
+ showRegister = false;
+ }}
+ class="mt-4 text-sm text-[hsl(var(--primary))]"
+ >
+ Zurück zum Login
+
+
+ {:else if show2FA}
+
+
+ Zwei-Faktor-Authentifizierung
+
+
e.key === 'Enter' && handle2FA()}
+ />
+ {#if error}
{error}
{/if}
+
+ {isLoading ? 'Prüfe...' : 'Bestätigen'}
+
+
+ {:else}
+
+
+ {showForgotPassword
+ ? 'Passwort zurücksetzen'
+ : showRegister
+ ? $_('auth.register')
+ : $_('auth.login')}
+
+
+
+
+ {#if !showForgotPassword}
+
+ e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())}
+ />
+ {/if}
+
+
+ {#if error}
{error}
{/if}
+
+
+ {isLoading
+ ? $_('common.loading')
+ : showForgotPassword
+ ? 'Link senden'
+ : showRegister
+ ? $_('auth.register')
+ : $_('auth.login')}
+
+
+ {#if !showForgotPassword && authStore.isPasskeyAvailable()}
+
+ 🔑 Mit Passkey anmelden
+
+ {/if}
+
+
+ {#if showForgotPassword}
+ {
+ showForgotPassword = false;
+ error = '';
+ }}
+ class="text-[hsl(var(--primary))]"
+ >
+ Zurück zum Login
+
+ {:else}
+ {
+ showForgotPassword = true;
+ error = '';
+ }}
+ class="text-[hsl(var(--muted-foreground))]"
+ >
+ {$_('auth.forgotPassword')}
+
+ {
+ showRegister = !showRegister;
+ error = '';
+ }}
+ class="text-[hsl(var(--primary))]"
+ >
+ {showRegister ? $_('auth.login') : $_('auth.register')}
+
+ {/if}
+
+
+ {/if}
+
+
+
+ {#each getPillAppItems() as app}
+ {#if app.id !== 'inventar'}
+
+ {app.label}
+
+ {/if}
+ {/each}
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/+error.svelte b/apps/inventar/apps/web/src/routes/+error.svelte
new file mode 100644
index 000000000..6ef4029e4
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/+error.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/+layout.svelte b/apps/inventar/apps/web/src/routes/+layout.svelte
new file mode 100644
index 000000000..69d701b16
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/+layout.svelte
@@ -0,0 +1,35 @@
+
+
+{#if ready}
+
+ {@render children()}
+
+{:else}
+
+{/if}
diff --git a/apps/inventar/apps/web/src/routes/health/+server.ts b/apps/inventar/apps/web/src/routes/health/+server.ts
new file mode 100644
index 000000000..82171862e
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/health/+server.ts
@@ -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',
+ });
+};
diff --git a/apps/inventar/apps/web/src/routes/offline/+page.svelte b/apps/inventar/apps/web/src/routes/offline/+page.svelte
new file mode 100644
index 000000000..332e487ac
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/offline/+page.svelte
@@ -0,0 +1,27 @@
+
+
+
+ Offline | Inventar
+
+
+
+
+
+ 📦
+
+
Offline
+
+ Du bist gerade nicht mit dem Internet verbunden.
+
+
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
+
+
+
diff --git a/apps/inventar/apps/web/src/routes/offline/+page.ts b/apps/inventar/apps/web/src/routes/offline/+page.ts
new file mode 100644
index 000000000..a3d15781a
--- /dev/null
+++ b/apps/inventar/apps/web/src/routes/offline/+page.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/apps/inventar/apps/web/static/favicon.svg b/apps/inventar/apps/web/static/favicon.svg
new file mode 100644
index 000000000..05183ba37
--- /dev/null
+++ b/apps/inventar/apps/web/static/favicon.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/inventar/apps/web/static/icons/icon.svg b/apps/inventar/apps/web/static/icons/icon.svg
new file mode 100644
index 000000000..3aec712ad
--- /dev/null
+++ b/apps/inventar/apps/web/static/icons/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/inventar/apps/web/svelte.config.js b/apps/inventar/apps/web/svelte.config.js
new file mode 100644
index 000000000..a7a917e4c
--- /dev/null
+++ b/apps/inventar/apps/web/svelte.config.js
@@ -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;
diff --git a/apps/inventar/apps/web/tsconfig.json b/apps/inventar/apps/web/tsconfig.json
new file mode 100644
index 000000000..a8f10c8e3
--- /dev/null
+++ b/apps/inventar/apps/web/tsconfig.json
@@ -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"
+ }
+}
diff --git a/apps/inventar/apps/web/vite.config.ts b/apps/inventar/apps/web/vite.config.ts
new file mode 100644
index 000000000..06498f78a
--- /dev/null
+++ b/apps/inventar/apps/web/vite.config.ts
@@ -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',
+ },
+});
diff --git a/apps/inventar/package.json b/apps/inventar/package.json
new file mode 100644
index 000000000..710cc4918
--- /dev/null
+++ b/apps/inventar/package.json
@@ -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"
+}
diff --git a/apps/inventar/packages/shared/package.json b/apps/inventar/packages/shared/package.json
new file mode 100644
index 000000000..6cdc7dfaa
--- /dev/null
+++ b/apps/inventar/packages/shared/package.json
@@ -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"
+ }
+}
diff --git a/apps/inventar/packages/shared/src/constants/index.ts b/apps/inventar/packages/shared/src/constants/index.ts
new file mode 100644
index 000000000..64fe1efbb
--- /dev/null
+++ b/apps/inventar/packages/shared/src/constants/index.ts
@@ -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: [],
+ },
+ },
+];
diff --git a/apps/inventar/packages/shared/src/index.ts b/apps/inventar/packages/shared/src/index.ts
new file mode 100644
index 000000000..98583d97c
--- /dev/null
+++ b/apps/inventar/packages/shared/src/index.ts
@@ -0,0 +1,2 @@
+export * from './types/index.js';
+export * from './constants/index.js';
diff --git a/apps/inventar/packages/shared/src/types/index.ts b/apps/inventar/packages/shared/src/types/index.ts
new file mode 100644
index 000000000..f9119f834
--- /dev/null
+++ b/apps/inventar/packages/shared/src/types/index.ts
@@ -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;
+ 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;
+}
diff --git a/apps/inventar/packages/shared/tsconfig.json b/apps/inventar/packages/shared/tsconfig.json
new file mode 100644
index 000000000..d2e7ec71f
--- /dev/null
+++ b/apps/inventar/packages/shared/tsconfig.json
@@ -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/**/*"]
+}
diff --git a/apps/manacore/apps/landing/src/content/manascore/2026-03-27-inventar.md b/apps/manacore/apps/landing/src/content/manascore/2026-03-27-inventar.md
new file mode 100644
index 000000000..628357f1e
--- /dev/null
+++ b/apps/manacore/apps/landing/src/content/manascore/2026-03-27-inventar.md
@@ -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