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)} +
+
+ +
+ + +
+ + +
+
+ updateField(field.id, { name: (e.target as HTMLInputElement).value })} + /> + +
+ +
+ + + + updateField(field.id, { + placeholder: (e.target as HTMLInputElement).value || undefined, + })} + /> +
+ + + {#if field.type === 'currency'} + + updateField(field.id, { currencyCode: (e.target as HTMLInputElement).value })} + /> + {/if} + + + {#if field.type === 'select'} +
+
+ {#each field.options || [] as option, i} + + {option} + + + {/each} +
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + addOption(field.id); + } + }} + /> + +
+
+ {/if} +
+ + + +
+
+ {/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} +
+

Weiterleitung...

+
+{: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 + + +
+ +
+
+

{$_('nav.collections')}

+

+ {totalCollections} Sammlungen · {totalItems} Items +

+
+ + + + + {$_('collection.create')} + +
+ + + {#if collectionsStore.collections.length === 0} +
+ 📦 +

+ {$_('collection.noCollections')} +

+

+ Erstelle deine erste Sammlung, um loszulegen. +

+ + {$_('collection.create')} + +
+ {: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} +
+
+ + e.key === 'Enter' && save()} + /> + + + +
+
+ {/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'} + +
+ + + + + + {#each collection.schema.fields as field} + + {/each} + + + + + {#each sortedItems as item (item.id)} + goto(`/items/${item.id}`)} + > + + + {#each collection.schema.fields as field} + + {/each} + + + {/each} + +
{$_('item.name')}{$_('item.status')}{field.name}
{item.name} + +
+
+ {:else} + +
+ {#each sortedItems as item (item.id)} + + + {/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} +
+
+ +

{$_('collection.edit')}

+
+ +
+
+ + +
+ + + +
+

+ {$_('collection.customFields')} +

+ (schema = { fields })} /> +
+ +
+ + +
+
+
+{/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 + + +
+
+ +

{$_('collection.create')}

+
+ + {#if step === 'template'} + +
+

+ {$_('collection.selectTemplate')} +

+
+ {#each DEFAULT_TEMPLATES as template} + + {/each} +
+
+ {:else} + +
+
+
+ +
+ +
+ + + + +
+

+ {$_('collection.customFields')} +

+ (schema = { fields })} /> +
+ + +
+ + +
+
+ {/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} + + {/each} + {#if viewStore.hasActiveFilters} + + {/if} +
+
+ + +

{sortedItems.length} Items

+ + {#if sortedItems.length === 0} +
+ 🔍 +

{$_('common.noResults')}

+
+ {:else if viewStore.viewMode === 'grid'} +
+ {#each sortedItems as item (item.id)} + + {/each} +
+ {:else} +
+ {#each sortedItems as item (item.id)} + + {/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} +
+ +
+
+ + {#if !editing} +
+

{item.name}

+ {#if collection} +

+ {collection.icon} + {collection.name} +

+ {/if} +
+ {/if} +
+
+ {#if editing} + + + {:else} + + + {/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')} +

+
+ +
+ {/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')}

+ +
+ + {#if showForm} +
+

+ {editingId ? $_('location.edit') : $_('location.create')} +

+
+ + e.key === 'Enter' && save()} + /> + + +
+
+ {/if} + + {#if tree.length === 0} +
+ 📍 +

{$_('location.noLocations')}

+
+ {:else} +
+ {#snippet renderTree(locations: Location[], depth: number)} + {#each locations as location (location.id)} +
+
+ {location.icon || '📍'} + {location.name} + + + +
+ {#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)} + + {/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.

+ +
+ {:else if show2FA} +
+

+ Zwei-Faktor-Authentifizierung +

+ e.key === 'Enter' && handle2FA()} + /> + {#if error}

{error}

{/if} + +
+ {:else} +
+

+ {showForgotPassword + ? 'Passwort zurücksetzen' + : showRegister + ? $_('auth.register') + : $_('auth.login')} +

+ +
+ + {#if !showForgotPassword} + + e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())} + /> + {/if} +
+ + {#if error}

{error}

{/if} + + + + {#if !showForgotPassword && authStore.isPasskeyAvailable()} + + {/if} + +
+ {#if showForgotPassword} + + {:else} + + + {/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 @@ + + +
+
+

{$page.status}

+

+ {$page.error?.message || $_('error.notFound')} +

+ + {$_('error.backToHome')} + +
+
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} +
+
+
+

Laden...

+
+
+{/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. +

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