mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(inventar): add configurable inventory management app
SvelteKit web app with schema-less collections, 8 field types, 8 templates (electronics, books, furniture, etc.), 3 views (list/grid/table), hierarchical locations, categories, full-text search, and localStorage persistence. Includes ManaScore audit (28/100 Alpha), Dockerfile, SSO prep, and i18n (DE/EN). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2e4bb9bad7
commit
86d1da3587
65 changed files with 5050 additions and 0 deletions
59
apps/inventar/CLAUDE.md
Normal file
59
apps/inventar/CLAUDE.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Inventar
|
||||
|
||||
Configurable inventory management app - track anything with custom schemas.
|
||||
|
||||
**Web App Port:** 5190
|
||||
|
||||
## Project Overview
|
||||
|
||||
Inventar is a schema-less inventory management system built with SvelteKit. Users can create collections with custom field definitions, organize items by location and category, and view them in list/grid/table views.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| State | Svelte 5 runes ($state, $derived) with localStorage persistence |
|
||||
| Icons | @manacore/shared-icons (Phosphor) |
|
||||
| PWA | @vite-pwa/sveltekit + Workbox |
|
||||
| i18n | svelte-i18n (de, en) |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Collections**: Groups of items with a shared schema (custom field definitions)
|
||||
- **Templates**: Predefined schemas for common item types (electronics, books, etc.)
|
||||
- **Items**: Individual inventory entries with custom field values
|
||||
- **Locations**: Hierarchical places (House > Room > Cabinet > Shelf)
|
||||
- **Categories**: Flexible categorization with hierarchy
|
||||
- **Views**: List, Grid, Table views with saved filters
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm dev:inventar:web # Start web app on port 5190
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/inventar/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web client
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── (auth)/ # Login flow
|
||||
│ │ │ └── (app)/ # Authenticated app
|
||||
│ │ │ ├── collections/
|
||||
│ │ │ ├── items/
|
||||
│ │ │ ├── locations/
|
||||
│ │ │ └── categories/
|
||||
│ │ └── lib/
|
||||
│ │ ├── stores/ # Svelte 5 rune stores
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── i18n/ # Translations
|
||||
│ │ └── data/ # Templates, defaults
|
||||
│ └── static/
|
||||
└── packages/
|
||||
└── shared/ # Shared types & constants
|
||||
```
|
||||
53
apps/inventar/apps/web/Dockerfile
Normal file
53
apps/inventar/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Build stage - inherits pre-built shared packages from sveltekit-base
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
# Copy app-specific packages
|
||||
COPY apps/inventar/packages/shared ./apps/inventar/packages/shared
|
||||
COPY apps/inventar/apps/web ./apps/inventar/apps/web
|
||||
|
||||
# Install app-specific dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/inventar/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||
WORKDIR /app/apps/inventar/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
||||
COPY --from=builder /app/apps/inventar/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/inventar/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/inventar/apps/web/package.json ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5190
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5190
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5190/health || exit 1
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
50
apps/inventar/apps/web/package.json
Normal file
50
apps/inventar/apps/web/package.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@inventar/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^22.15.29",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.2.1",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.1",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@inventar/shared": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
54
apps/inventar/apps/web/src/app.css
Normal file
54
apps/inventar/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind/themes.css';
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
:root {
|
||||
--primary: 38 92% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--accent: 38 92% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
}
|
||||
|
||||
/* Status colors */
|
||||
.status-owned { color: #22c55e; }
|
||||
.status-lent { color: #f59e0b; }
|
||||
.status-stored { color: #3b82f6; }
|
||||
.status-for-sale { color: #a855f7; }
|
||||
.status-disposed { color: #6b7280; }
|
||||
|
||||
/* View transitions */
|
||||
.view-transition {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Grid view cards */
|
||||
.item-card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.item-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Field editor animations */
|
||||
.field-enter {
|
||||
animation: fieldSlideIn 0.2s ease forwards;
|
||||
}
|
||||
@keyframes fieldSlideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Location tree indentation */
|
||||
.location-tree-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.location-tree-item:hover {
|
||||
background-color: hsl(var(--accent) / 0.1);
|
||||
}
|
||||
16
apps/inventar/apps/web/src/app.d.ts
vendored
Normal file
16
apps/inventar/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
18
apps/inventar/apps/web/src/app.html
Normal file
18
apps/inventar/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f59e0b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<title>Inventar</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
12
apps/inventar/apps/web/src/hooks.client.ts
Normal file
12
apps/inventar/apps/web/src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
import { initErrorTracking, createErrorHandler } from '@manacore/shared-error-tracking';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'inventar-web',
|
||||
});
|
||||
|
||||
const errorHandler = createErrorHandler('inventar-web');
|
||||
|
||||
export const handleError: HandleClientError = ({ error, event }) => {
|
||||
errorHandler(error, { url: event.url.pathname });
|
||||
};
|
||||
33
apps/inventar/apps/web/src/hooks.server.ts
Normal file
33
apps/inventar/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
|
||||
const injectRuntimeEnv: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const glitchtipDsn = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
return html.replace(
|
||||
'</head>',
|
||||
`<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__="${authUrl}";
|
||||
window.__PUBLIC_GLITCHTIP_DSN__="${glitchtipDsn}";
|
||||
</script></head>`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Security headers
|
||||
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ${authUrl}; font-src 'self' data:;`
|
||||
);
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const handle = sequence(injectRuntimeEnv);
|
||||
27
apps/inventar/apps/web/src/lib/components/StatusBadge.svelte
Normal file
27
apps/inventar/apps/web/src/lib/components/StatusBadge.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type { ItemStatus } from '@inventar/shared';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
status: ItemStatus;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
let { status, size = 'sm' }: Props = $props();
|
||||
|
||||
const statusColors: Record<ItemStatus, string> = {
|
||||
owned: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
lent: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400',
|
||||
stored: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
for_sale: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
disposed: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-block rounded-full font-medium {statusColors[status]} {size === 'sm'
|
||||
? 'px-2 py-0.5 text-xs'
|
||||
: 'px-3 py-1 text-sm'}"
|
||||
>
|
||||
{$_(`status.${status}`)}
|
||||
</span>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import type { ViewMode } from '@inventar/shared';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
current: ViewMode;
|
||||
onchange: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
let { current, onchange }: Props = $props();
|
||||
|
||||
const modes: { value: ViewMode; icon: string }[] = [
|
||||
{ value: 'list', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' },
|
||||
{
|
||||
value: 'grid',
|
||||
icon: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
|
||||
},
|
||||
{ value: 'table', icon: 'M3 10h18M3 14h18M3 18h18M3 6h18M3 6v12M21 6v12M9 6v12M15 6v12' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onchange(mode.value)}
|
||||
class="p-2 transition-colors {current === mode.value
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'} {mode.value ===
|
||||
'list'
|
||||
? 'rounded-l-lg'
|
||||
: mode.value === 'table'
|
||||
? 'rounded-r-lg'
|
||||
: ''}"
|
||||
title={$_(`view.${mode.value}`)}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mode.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts">
|
||||
import type { FieldDefinition } from '@inventar/shared';
|
||||
|
||||
interface Props {
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
onchange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
let { field, value, onchange }: Props = $props();
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
case 'currency':
|
||||
onchange(target.value ? Number(target.value) : undefined);
|
||||
break;
|
||||
case 'checkbox':
|
||||
onchange(target.checked);
|
||||
break;
|
||||
default:
|
||||
onchange(target.value || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
onchange(target.value || undefined);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:ring-offset-1';
|
||||
</script>
|
||||
|
||||
{#if field.type === 'text'}
|
||||
<input
|
||||
type="text"
|
||||
value={String(value || '')}
|
||||
placeholder={field.placeholder || field.name}
|
||||
class={inputClass}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
{:else if field.type === 'number'}
|
||||
<input
|
||||
type="number"
|
||||
value={value !== undefined && value !== null ? Number(value) : ''}
|
||||
placeholder={field.placeholder || field.name}
|
||||
class={inputClass}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
{:else if field.type === 'currency'}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={value !== undefined && value !== null ? Number(value) : ''}
|
||||
placeholder="0.00"
|
||||
class="{inputClass} flex-1"
|
||||
oninput={handleInput}
|
||||
/>
|
||||
<span class="flex items-center text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{field.currencyCode || 'EUR'}
|
||||
</span>
|
||||
</div>
|
||||
{:else if field.type === 'date'}
|
||||
<input type="date" value={String(value || '')} class={inputClass} oninput={handleInput} />
|
||||
{:else if field.type === 'checkbox'}
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
class="h-4 w-4 rounded border-[hsl(var(--border))] text-[hsl(var(--primary))]"
|
||||
onchange={handleInput}
|
||||
/>
|
||||
<span class="text-sm text-[hsl(var(--foreground))]">{field.name}</span>
|
||||
</label>
|
||||
{:else if field.type === 'select'}
|
||||
<select value={String(value || '')} class={inputClass} onchange={handleSelectChange}>
|
||||
<option value="">— Auswahlen —</option>
|
||||
{#each field.options || [] as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if field.type === 'url'}
|
||||
<input
|
||||
type="url"
|
||||
value={String(value || '')}
|
||||
placeholder={field.placeholder || 'https://...'}
|
||||
class={inputClass}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
{:else if field.type === 'tags'}
|
||||
{@const currentTags = Array.isArray(value) ? (value as string[]) : []}
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each currentTags as tag, i}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
onclick={() => onchange(currentTags.filter((_, idx) => idx !== i))}>x</button
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tag eingeben + Enter"
|
||||
class={inputClass}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLInputElement;
|
||||
const newTag = target.value.trim();
|
||||
if (newTag && !currentTags.includes(newTag)) {
|
||||
onchange([...currentTags, newTag]);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import type { FieldDefinition } from '@inventar/shared';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
let { field, value }: Props = $props();
|
||||
|
||||
function formatCurrency(val: unknown, code?: string): string {
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return String(val || '');
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: code || 'EUR' }).format(
|
||||
num
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(val: unknown): string {
|
||||
if (!val) return '';
|
||||
try {
|
||||
return format(new Date(String(val)), 'dd.MM.yyyy', { locale: de });
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if value === undefined || value === null || value === ''}
|
||||
<span class="text-[hsl(var(--muted-foreground))] italic">—</span>
|
||||
{:else if field.type === 'checkbox'}
|
||||
{#if value}
|
||||
<span class="text-green-500">✓</span>
|
||||
{:else}
|
||||
<span class="text-[hsl(var(--muted-foreground))]">✗</span>
|
||||
{/if}
|
||||
{:else if field.type === 'currency'}
|
||||
<span>{formatCurrency(value, field.currencyCode)}</span>
|
||||
{:else if field.type === 'date'}
|
||||
<span>{formatDate(value)}</span>
|
||||
{:else if field.type === 'url'}
|
||||
<a
|
||||
href={String(value)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[hsl(var(--primary))] underline hover:no-underline"
|
||||
>
|
||||
{String(value)
|
||||
.replace(/^https?:\/\//, '')
|
||||
.slice(0, 40)}
|
||||
</a>
|
||||
{:else if field.type === 'select'}
|
||||
<span
|
||||
class="inline-block rounded-full bg-[hsl(var(--accent)/0.15)] px-2 py-0.5 text-xs font-medium text-[hsl(var(--accent-foreground))]"
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
{:else if field.type === 'tags'}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Array.isArray(value) ? value : [] as tag}
|
||||
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if field.type === 'number'}
|
||||
<span>{Number(value).toLocaleString('de-DE')}</span>
|
||||
{:else}
|
||||
<span>{String(value)}</span>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import type { FieldDefinition, FieldType } from '@inventar/shared';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
fields: FieldDefinition[];
|
||||
onchange: (fields: FieldDefinition[]) => void;
|
||||
}
|
||||
|
||||
let { fields, onchange }: Props = $props();
|
||||
|
||||
const fieldTypes: { value: FieldType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Zahl' },
|
||||
{ value: 'date', label: 'Datum' },
|
||||
{ value: 'select', label: 'Auswahl' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'currency', label: 'Wahrung' },
|
||||
];
|
||||
|
||||
function addField() {
|
||||
const newField: FieldDefinition = {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'text',
|
||||
order: fields.length,
|
||||
};
|
||||
onchange([...fields, newField]);
|
||||
}
|
||||
|
||||
function updateField(id: string, updates: Partial<FieldDefinition>) {
|
||||
onchange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
onchange(fields.filter((f) => f.id !== id).map((f, i) => ({ ...f, order: i })));
|
||||
}
|
||||
|
||||
function moveField(id: string, direction: 'up' | 'down') {
|
||||
const index = fields.findIndex((f) => f.id === id);
|
||||
if (index === -1) return;
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= fields.length) return;
|
||||
const newFields = [...fields];
|
||||
[newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]];
|
||||
onchange(newFields.map((f, i) => ({ ...f, order: i })));
|
||||
}
|
||||
|
||||
let editingOptions = $state<string | null>(null);
|
||||
let newOption = $state('');
|
||||
|
||||
function addOption(fieldId: string) {
|
||||
if (!newOption.trim()) return;
|
||||
const field = fields.find((f) => f.id === fieldId);
|
||||
if (!field) return;
|
||||
updateField(fieldId, { options: [...(field.options || []), newOption.trim()] });
|
||||
newOption = '';
|
||||
}
|
||||
|
||||
function removeOption(fieldId: string, index: number) {
|
||||
const field = fields.find((f) => f.id === fieldId);
|
||||
if (!field) return;
|
||||
updateField(fieldId, { options: field.options?.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each fields.sort((a, b) => a.order - b.order) as field, index (field.id)}
|
||||
<div
|
||||
class="field-enter rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<!-- Reorder buttons -->
|
||||
<div class="flex flex-col gap-0.5 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => moveField(field.id, 'up')}
|
||||
disabled={index === 0}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
|
||||
>▲</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => moveField(field.id, 'down')}
|
||||
disabled={index === fields.length - 1}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
|
||||
>▼</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Field config -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={field.name}
|
||||
placeholder="Feldname"
|
||||
class="{inputClass} flex-1"
|
||||
oninput={(e) => updateField(field.id, { name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
<select
|
||||
value={field.type}
|
||||
class="{inputClass} w-32"
|
||||
onchange={(e) =>
|
||||
updateField(field.id, { type: (e.target as HTMLSelectElement).value as FieldType })}
|
||||
>
|
||||
{#each fieldTypes as ft}
|
||||
<option value={ft.value}>{ft.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required || false}
|
||||
onchange={(e) =>
|
||||
updateField(field.id, { required: (e.target as HTMLInputElement).checked })}
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
Pflichtfeld
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder || ''}
|
||||
placeholder="Platzhalter (optional)"
|
||||
class="{inputClass} flex-1 text-xs"
|
||||
oninput={(e) =>
|
||||
updateField(field.id, {
|
||||
placeholder: (e.target as HTMLInputElement).value || undefined,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Currency code for currency fields -->
|
||||
{#if field.type === 'currency'}
|
||||
<input
|
||||
type="text"
|
||||
value={field.currencyCode || 'EUR'}
|
||||
placeholder="Wahrungscode (z.B. EUR)"
|
||||
class="{inputClass} text-xs"
|
||||
oninput={(e) =>
|
||||
updateField(field.id, { currencyCode: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Options for select fields -->
|
||||
{#if field.type === 'select'}
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each field.options || [] as option, i}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
|
||||
>
|
||||
{option}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeOption(field.id, i)}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-red-500">x</button
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newOption}
|
||||
placeholder="Neue Option"
|
||||
class="{inputClass} flex-1 text-xs"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addOption(field.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addOption(field.id)}
|
||||
class="rounded bg-[hsl(var(--primary))] px-2 py-1 text-xs text-[hsl(var(--primary-foreground))]"
|
||||
>+</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeField(field.id)}
|
||||
class="mt-1 text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
title={$_('field.removeField')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={addField}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary))] hover:text-[hsl(var(--primary))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$_('collection.addField')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
let { count = 6 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(count) as _}
|
||||
<div
|
||||
class="animate-pulse rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-lg bg-[hsl(var(--muted))]"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-32 rounded bg-[hsl(var(--muted))]"></div>
|
||||
<div class="h-3 w-48 rounded bg-[hsl(var(--muted))]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="h-3 w-16 rounded bg-[hsl(var(--muted))]"></div>
|
||||
<div class="flex gap-1">
|
||||
<div class="h-4 w-12 rounded bg-[hsl(var(--muted))]"></div>
|
||||
<div class="h-4 w-12 rounded bg-[hsl(var(--muted))]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
let { count = 8 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each Array(count) as _}
|
||||
<div
|
||||
class="animate-pulse flex items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
|
||||
>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-4 w-40 rounded bg-[hsl(var(--muted))]"></div>
|
||||
<div class="h-5 w-16 rounded-full bg-[hsl(var(--muted))]"></div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="h-3 w-24 rounded bg-[hsl(var(--muted))]"></div>
|
||||
<div class="h-3 w-20 rounded bg-[hsl(var(--muted))]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CollectionListSkeleton } from './CollectionListSkeleton.svelte';
|
||||
export { default as ItemListSkeleton } from './ItemListSkeleton.svelte';
|
||||
38
apps/inventar/apps/web/src/lib/i18n/index.ts
Normal file
38
apps/inventar/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
export const supportedLocales = ['de', 'en'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
const defaultLocale = 'de';
|
||||
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('inventar_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('inventar_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
148
apps/inventar/apps/web/src/lib/i18n/locales/de.json
Normal file
148
apps/inventar/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Inventar",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"nav": {
|
||||
"collections": "Sammlungen",
|
||||
"allItems": "Alle Items",
|
||||
"locations": "Standorte",
|
||||
"categories": "Kategorien",
|
||||
"tags": "Tags",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"collection": {
|
||||
"create": "Sammlung erstellen",
|
||||
"edit": "Sammlung bearbeiten",
|
||||
"delete": "Sammlung löschen",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"icon": "Symbol",
|
||||
"color": "Farbe",
|
||||
"template": "Vorlage",
|
||||
"schema": "Schema",
|
||||
"noCollections": "Keine Sammlungen",
|
||||
"itemCount": "{count} Items",
|
||||
"selectTemplate": "Vorlage wählen",
|
||||
"customFields": "Eigene Felder",
|
||||
"addField": "Feld hinzufügen"
|
||||
},
|
||||
"item": {
|
||||
"create": "Item erstellen",
|
||||
"edit": "Item bearbeiten",
|
||||
"delete": "Item löschen",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"status": "Status",
|
||||
"quantity": "Menge",
|
||||
"location": "Standort",
|
||||
"category": "Kategorie",
|
||||
"tags": "Tags",
|
||||
"photos": "Fotos",
|
||||
"notes": "Notizen",
|
||||
"documents": "Dokumente",
|
||||
"noItems": "Keine Items",
|
||||
"addPhoto": "Foto hinzufügen",
|
||||
"addNote": "Notiz hinzufügen",
|
||||
"addDocument": "Dokument hinzufügen"
|
||||
},
|
||||
"status": {
|
||||
"owned": "Besitzt",
|
||||
"lent": "Verliehen",
|
||||
"stored": "Eingelagert",
|
||||
"for_sale": "Zu verkaufen",
|
||||
"disposed": "Entsorgt"
|
||||
},
|
||||
"location": {
|
||||
"create": "Standort erstellen",
|
||||
"edit": "Standort bearbeiten",
|
||||
"delete": "Standort löschen",
|
||||
"name": "Name",
|
||||
"parent": "Übergeordnet",
|
||||
"noLocations": "Keine Standorte",
|
||||
"addSub": "Unterstandort hinzufügen"
|
||||
},
|
||||
"category": {
|
||||
"create": "Kategorie erstellen",
|
||||
"edit": "Kategorie bearbeiten",
|
||||
"delete": "Kategorie löschen",
|
||||
"name": "Name",
|
||||
"noCategories": "Keine Kategorien"
|
||||
},
|
||||
"field": {
|
||||
"text": "Text",
|
||||
"number": "Zahl",
|
||||
"date": "Datum",
|
||||
"select": "Auswahl",
|
||||
"tags": "Tags",
|
||||
"checkbox": "Checkbox",
|
||||
"url": "URL",
|
||||
"currency": "Währung",
|
||||
"required": "Pflichtfeld",
|
||||
"placeholder": "Platzhalter",
|
||||
"options": "Optionen",
|
||||
"addOption": "Option hinzufügen",
|
||||
"removeField": "Feld entfernen"
|
||||
},
|
||||
"template": {
|
||||
"electronics": "Elektronik",
|
||||
"books": "Bücher",
|
||||
"furniture": "Möbel",
|
||||
"clothing": "Kleidung",
|
||||
"tools": "Werkzeug",
|
||||
"kitchen": "Küche",
|
||||
"media": "Medien",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"view": {
|
||||
"list": "Liste",
|
||||
"grid": "Kacheln",
|
||||
"table": "Tabelle"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"saved": "Gespeicherte Filter",
|
||||
"save": "Filter speichern",
|
||||
"clear": "Filter zurücksetzen"
|
||||
},
|
||||
"purchase": {
|
||||
"title": "Kaufdaten",
|
||||
"price": "Preis",
|
||||
"currency": "Währung",
|
||||
"date": "Kaufdatum",
|
||||
"retailer": "Händler",
|
||||
"warranty": "Garantie bis",
|
||||
"receipt": "Beleg"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"register": "Registrieren",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"forgotPassword": "Passwort vergessen?"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen",
|
||||
"search": "Suchen",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolgreich",
|
||||
"loading": "Laden...",
|
||||
"noResults": "Keine Ergebnisse",
|
||||
"confirm": "Bestätigen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"create": "Erstellen"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Seite nicht gefunden",
|
||||
"backToHome": "Zurück zur Startseite"
|
||||
}
|
||||
}
|
||||
148
apps/inventar/apps/web/src/lib/i18n/locales/en.json
Normal file
148
apps/inventar/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Inventar",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"collections": "Collections",
|
||||
"allItems": "All Items",
|
||||
"locations": "Locations",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"collection": {
|
||||
"create": "Create Collection",
|
||||
"edit": "Edit Collection",
|
||||
"delete": "Delete Collection",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"template": "Template",
|
||||
"schema": "Schema",
|
||||
"noCollections": "No collections",
|
||||
"itemCount": "{count} items",
|
||||
"selectTemplate": "Select template",
|
||||
"customFields": "Custom fields",
|
||||
"addField": "Add field"
|
||||
},
|
||||
"item": {
|
||||
"create": "Create Item",
|
||||
"edit": "Edit Item",
|
||||
"delete": "Delete Item",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"status": "Status",
|
||||
"quantity": "Quantity",
|
||||
"location": "Location",
|
||||
"category": "Category",
|
||||
"tags": "Tags",
|
||||
"photos": "Photos",
|
||||
"notes": "Notes",
|
||||
"documents": "Documents",
|
||||
"noItems": "No items",
|
||||
"addPhoto": "Add photo",
|
||||
"addNote": "Add note",
|
||||
"addDocument": "Add document"
|
||||
},
|
||||
"status": {
|
||||
"owned": "Owned",
|
||||
"lent": "Lent",
|
||||
"stored": "Stored",
|
||||
"for_sale": "For Sale",
|
||||
"disposed": "Disposed"
|
||||
},
|
||||
"location": {
|
||||
"create": "Create Location",
|
||||
"edit": "Edit Location",
|
||||
"delete": "Delete Location",
|
||||
"name": "Name",
|
||||
"parent": "Parent",
|
||||
"noLocations": "No locations",
|
||||
"addSub": "Add sub-location"
|
||||
},
|
||||
"category": {
|
||||
"create": "Create Category",
|
||||
"edit": "Edit Category",
|
||||
"delete": "Delete Category",
|
||||
"name": "Name",
|
||||
"noCategories": "No categories"
|
||||
},
|
||||
"field": {
|
||||
"text": "Text",
|
||||
"number": "Number",
|
||||
"date": "Date",
|
||||
"select": "Select",
|
||||
"tags": "Tags",
|
||||
"checkbox": "Checkbox",
|
||||
"url": "URL",
|
||||
"currency": "Currency",
|
||||
"required": "Required",
|
||||
"placeholder": "Placeholder",
|
||||
"options": "Options",
|
||||
"addOption": "Add option",
|
||||
"removeField": "Remove field"
|
||||
},
|
||||
"template": {
|
||||
"electronics": "Electronics",
|
||||
"books": "Books",
|
||||
"furniture": "Furniture",
|
||||
"clothing": "Clothing",
|
||||
"tools": "Tools",
|
||||
"kitchen": "Kitchen",
|
||||
"media": "Media",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"view": {
|
||||
"list": "List",
|
||||
"grid": "Grid",
|
||||
"table": "Table"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"saved": "Saved Filters",
|
||||
"save": "Save Filter",
|
||||
"clear": "Clear Filters"
|
||||
},
|
||||
"purchase": {
|
||||
"title": "Purchase Data",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"date": "Purchase Date",
|
||||
"retailer": "Retailer",
|
||||
"warranty": "Warranty Until",
|
||||
"receipt": "Receipt"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"register": "Register",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"forgotPassword": "Forgot password?"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"loading": "Loading...",
|
||||
"noResults": "No results",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"create": "Create"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Page not found",
|
||||
"backToHome": "Back to home"
|
||||
}
|
||||
}
|
||||
18
apps/inventar/apps/web/src/lib/services/feedback.ts
Normal file
18
apps/inventar/apps/web/src/lib/services/feedback.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||
}
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
appId: 'inventar',
|
||||
authUrl: getAuthUrl,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
237
apps/inventar/apps/web/src/lib/stores/auth.svelte.ts
Normal file
237
apps/inventar/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
|
||||
const DEV_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
return import.meta.env.DEV ? DEV_AUTH_URL : '';
|
||||
}
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL;
|
||||
}
|
||||
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
if (!authenticated) {
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) authenticated = true;
|
||||
}
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (!result.success) return { success: false, error: result.error || 'Login failed' };
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService)
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
if (!result.success)
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
if (result.needsVerification) return { success: true, needsVerification: true };
|
||||
const signInResult = await authStore.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
needsVerification: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await authService.signOut();
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
}
|
||||
user = null;
|
||||
},
|
||||
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
try {
|
||||
const redirectTo = browser ? window.location.origin : undefined;
|
||||
const result = await authService.forgotPassword(email, redirectTo);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error || 'Password reset failed' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
|
||||
async resetPasswordWithToken(token: string, newPassword: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
try {
|
||||
const result = await authService.resetPassword(token, newPassword);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error || 'Failed to reset password' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
|
||||
async verifyTwoFactor(code: string, trustDevice?: boolean) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
const result = await authService.verifyTwoFactor(code, trustDevice);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async verifyBackupCode(code: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
const result = await authService.verifyBackupCode(code);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async sendMagicLink(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
return authService.sendMagicLink(email);
|
||||
},
|
||||
|
||||
isPasskeyAvailable(): boolean {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return false;
|
||||
return authService.isPasskeyAvailable();
|
||||
},
|
||||
|
||||
async signInWithPasskey() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
try {
|
||||
const result = await authService.signInWithPasskey();
|
||||
if (!result.success)
|
||||
return { success: false, error: result.error || 'Passkey authentication failed' };
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return null;
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) return null;
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
|
||||
async resendVerificationEmail(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available on server' };
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error || 'Failed to resend verification email' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
};
|
||||
100
apps/inventar/apps/web/src/lib/stores/categories.svelte.ts
Normal file
100
apps/inventar/apps/web/src/lib/stores/categories.svelte.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Category } from '@inventar/shared';
|
||||
|
||||
const STORAGE_KEY = 'inventar_categories';
|
||||
|
||||
function loadFromStorage(): Category[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(categories: Category[]) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(categories));
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
let categories = $state<Category[]>([]);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const categoriesStore = {
|
||||
get categories() {
|
||||
return categories;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (initialized) return;
|
||||
categories = loadFromStorage();
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
getById(id: string): Category | undefined {
|
||||
return categories.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
getRootCategories(): Category[] {
|
||||
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
getChildren(parentId: string): Category[] {
|
||||
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
getTree(): Category[] {
|
||||
const buildTree = (parentId?: string): Category[] => {
|
||||
return categories
|
||||
.filter((c) => c.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((c) => ({ ...c, children: buildTree(c.id) }));
|
||||
};
|
||||
return buildTree(undefined);
|
||||
},
|
||||
|
||||
create(data: { name: string; icon?: string; color?: string; parentId?: string }): Category {
|
||||
const now = new Date().toISOString();
|
||||
const siblings = categories.filter((c) => c.parentId === data.parentId);
|
||||
|
||||
const category: Category = {
|
||||
id: generateId(),
|
||||
parentId: data.parentId,
|
||||
name: data.name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
order: siblings.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
categories = [...categories, category];
|
||||
saveToStorage(categories);
|
||||
return category;
|
||||
},
|
||||
|
||||
update(id: string, data: Partial<Pick<Category, 'name' | 'icon' | 'color'>>) {
|
||||
categories = categories.map((c) =>
|
||||
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
|
||||
);
|
||||
saveToStorage(categories);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
const idsToDelete = new Set<string>();
|
||||
const collectIds = (parentId: string) => {
|
||||
idsToDelete.add(parentId);
|
||||
categories.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
|
||||
};
|
||||
collectIds(id);
|
||||
categories = categories.filter((c) => !idsToDelete.has(c.id));
|
||||
saveToStorage(categories);
|
||||
},
|
||||
};
|
||||
102
apps/inventar/apps/web/src/lib/stores/collections.svelte.ts
Normal file
102
apps/inventar/apps/web/src/lib/stores/collections.svelte.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Collection, CollectionSchema } from '@inventar/shared';
|
||||
|
||||
const STORAGE_KEY = 'inventar_collections';
|
||||
|
||||
function loadFromStorage(): Collection[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(collections: Collection[]) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collections));
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
let collections = $state<Collection[]>([]);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const collectionsStore = {
|
||||
get collections() {
|
||||
return collections;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (initialized) return;
|
||||
collections = loadFromStorage();
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
getById(id: string): Collection | undefined {
|
||||
return collections.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
create(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
schema: CollectionSchema;
|
||||
templateId?: string;
|
||||
}): Collection {
|
||||
const now = new Date().toISOString();
|
||||
const collection: Collection = {
|
||||
id: generateId(),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
schema: data.schema,
|
||||
templateId: data.templateId,
|
||||
order: collections.length,
|
||||
itemCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
collections = [...collections, collection];
|
||||
saveToStorage(collections);
|
||||
return collection;
|
||||
},
|
||||
|
||||
update(
|
||||
id: string,
|
||||
data: Partial<Pick<Collection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
|
||||
) {
|
||||
collections = collections.map((c) =>
|
||||
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
|
||||
);
|
||||
saveToStorage(collections);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
collections = collections.filter((c) => c.id !== id);
|
||||
saveToStorage(collections);
|
||||
},
|
||||
|
||||
reorder(orderedIds: string[]) {
|
||||
collections = orderedIds
|
||||
.map((id, index) => {
|
||||
const c = collections.find((col) => col.id === id);
|
||||
return c ? { ...c, order: index } : null;
|
||||
})
|
||||
.filter((c): c is Collection => c !== null);
|
||||
saveToStorage(collections);
|
||||
},
|
||||
|
||||
updateItemCount(collectionId: string, count: number) {
|
||||
collections = collections.map((c) => (c.id === collectionId ? { ...c, itemCount: count } : c));
|
||||
saveToStorage(collections);
|
||||
},
|
||||
};
|
||||
260
apps/inventar/apps/web/src/lib/stores/items.svelte.ts
Normal file
260
apps/inventar/apps/web/src/lib/stores/items.svelte.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type {
|
||||
Item,
|
||||
ItemStatus,
|
||||
ItemNote,
|
||||
ItemPhoto,
|
||||
PurchaseData,
|
||||
SortOption,
|
||||
} from '@inventar/shared';
|
||||
|
||||
const STORAGE_KEY = 'inventar_items';
|
||||
|
||||
function loadFromStorage(): Item[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(items: Item[]) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const itemsStore = {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (initialized) return;
|
||||
items = loadFromStorage();
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
getById(id: string): Item | undefined {
|
||||
return items.find((i) => i.id === id);
|
||||
},
|
||||
|
||||
getByCollection(collectionId: string): Item[] {
|
||||
return items.filter((i) => i.collectionId === collectionId);
|
||||
},
|
||||
|
||||
getFiltered(filters: {
|
||||
collectionId?: string;
|
||||
locationId?: string;
|
||||
categoryId?: string;
|
||||
status?: ItemStatus[];
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
}): Item[] {
|
||||
let result = items;
|
||||
|
||||
if (filters.collectionId) {
|
||||
result = result.filter((i) => i.collectionId === filters.collectionId);
|
||||
}
|
||||
if (filters.locationId) {
|
||||
result = result.filter((i) => i.locationId === filters.locationId);
|
||||
}
|
||||
if (filters.categoryId) {
|
||||
result = result.filter((i) => i.categoryId === filters.categoryId);
|
||||
}
|
||||
if (filters.status?.length) {
|
||||
result = result.filter((i) => filters.status!.includes(i.status));
|
||||
}
|
||||
if (filters.tagIds?.length) {
|
||||
result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t)));
|
||||
}
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.description?.toLowerCase().includes(q) ||
|
||||
Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
getSorted(itemList: Item[], sort: SortOption): Item[] {
|
||||
return [...itemList].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sort.field) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'createdAt':
|
||||
cmp = a.createdAt.localeCompare(b.createdAt);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = a.updatedAt.localeCompare(b.updatedAt);
|
||||
break;
|
||||
case 'status':
|
||||
cmp = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'quantity':
|
||||
cmp = a.quantity - b.quantity;
|
||||
break;
|
||||
}
|
||||
return sort.direction === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
},
|
||||
|
||||
create(data: {
|
||||
collectionId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: ItemStatus;
|
||||
quantity?: number;
|
||||
locationId?: string;
|
||||
categoryId?: string;
|
||||
fieldValues?: Record<string, unknown>;
|
||||
purchaseData?: PurchaseData;
|
||||
tags?: string[];
|
||||
}): Item {
|
||||
const now = new Date().toISOString();
|
||||
const item: Item = {
|
||||
id: generateId(),
|
||||
collectionId: data.collectionId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: data.status || 'owned',
|
||||
quantity: data.quantity || 1,
|
||||
locationId: data.locationId,
|
||||
categoryId: data.categoryId,
|
||||
fieldValues: data.fieldValues || {},
|
||||
purchaseData: data.purchaseData,
|
||||
photos: [],
|
||||
notes: [],
|
||||
documents: [],
|
||||
tags: data.tags || [],
|
||||
order: items.filter((i) => i.collectionId === data.collectionId).length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
items = [...items, item];
|
||||
saveToStorage(items);
|
||||
return item;
|
||||
},
|
||||
|
||||
update(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Item,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'status'
|
||||
| 'quantity'
|
||||
| 'locationId'
|
||||
| 'categoryId'
|
||||
| 'fieldValues'
|
||||
| 'purchaseData'
|
||||
| 'tags'
|
||||
>
|
||||
>
|
||||
) {
|
||||
items = items.map((i) =>
|
||||
i.id === id ? { ...i, ...data, updatedAt: new Date().toISOString() } : i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
items = items.filter((i) => i.id !== id);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
deleteByCollection(collectionId: string) {
|
||||
items = items.filter((i) => i.collectionId !== collectionId);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
addNote(itemId: string, content: string) {
|
||||
const now = new Date().toISOString();
|
||||
const note: ItemNote = { id: generateId(), content, createdAt: now, updatedAt: now };
|
||||
items = items.map((i) =>
|
||||
i.id === itemId ? { ...i, notes: [...i.notes, note], updatedAt: now } : i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
updateNote(itemId: string, noteId: string, content: string) {
|
||||
const now = new Date().toISOString();
|
||||
items = items.map((i) =>
|
||||
i.id === itemId
|
||||
? {
|
||||
...i,
|
||||
notes: i.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)),
|
||||
updatedAt: now,
|
||||
}
|
||||
: i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
deleteNote(itemId: string, noteId: string) {
|
||||
items = items.map((i) =>
|
||||
i.id === itemId
|
||||
? {
|
||||
...i,
|
||||
notes: i.notes.filter((n) => n.id !== noteId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
addPhoto(itemId: string, photo: Omit<ItemPhoto, 'id' | 'order'>) {
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
const newPhoto: ItemPhoto = { ...photo, id: generateId(), order: item?.photos.length || 0 };
|
||||
items = items.map((i) =>
|
||||
i.id === itemId
|
||||
? { ...i, photos: [...i.photos, newPhoto], updatedAt: new Date().toISOString() }
|
||||
: i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
deletePhoto(itemId: string, photoId: string) {
|
||||
items = items.map((i) =>
|
||||
i.id === itemId
|
||||
? {
|
||||
...i,
|
||||
photos: i.photos.filter((p) => p.id !== photoId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: i
|
||||
);
|
||||
saveToStorage(items);
|
||||
},
|
||||
|
||||
getCountByCollection(collectionId: string): number {
|
||||
return items.filter((i) => i.collectionId === collectionId).length;
|
||||
},
|
||||
|
||||
getTotalCount(): number {
|
||||
return items.length;
|
||||
},
|
||||
|
||||
getCountByStatus(status: ItemStatus): number {
|
||||
return items.filter((i) => i.status === status).length;
|
||||
},
|
||||
};
|
||||
124
apps/inventar/apps/web/src/lib/stores/locations.svelte.ts
Normal file
124
apps/inventar/apps/web/src/lib/stores/locations.svelte.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Location } from '@inventar/shared';
|
||||
|
||||
const STORAGE_KEY = 'inventar_locations';
|
||||
|
||||
function loadFromStorage(): Location[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(locations: Location[]) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(locations));
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function buildPath(locations: Location[], parentId?: string): string {
|
||||
if (!parentId) return '';
|
||||
const parent = locations.find((l) => l.id === parentId);
|
||||
if (!parent) return '';
|
||||
return parent.path ? `${parent.path}/${parent.name}` : parent.name;
|
||||
}
|
||||
|
||||
function getDepth(locations: Location[], parentId?: string): number {
|
||||
if (!parentId) return 0;
|
||||
const parent = locations.find((l) => l.id === parentId);
|
||||
return parent ? parent.depth + 1 : 0;
|
||||
}
|
||||
|
||||
let locations = $state<Location[]>([]);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const locationsStore = {
|
||||
get locations() {
|
||||
return locations;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (initialized) return;
|
||||
locations = loadFromStorage();
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
getById(id: string): Location | undefined {
|
||||
return locations.find((l) => l.id === id);
|
||||
},
|
||||
|
||||
getRootLocations(): Location[] {
|
||||
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
getChildren(parentId: string): Location[] {
|
||||
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
getTree(): Location[] {
|
||||
const buildTree = (parentId?: string): Location[] => {
|
||||
return locations
|
||||
.filter((l) => l.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((l) => ({ ...l, children: buildTree(l.id) }));
|
||||
};
|
||||
return buildTree(undefined);
|
||||
},
|
||||
|
||||
getFullPath(id: string): string {
|
||||
const location = locations.find((l) => l.id === id);
|
||||
if (!location) return '';
|
||||
return location.path ? `${location.path}/${location.name}` : location.name;
|
||||
},
|
||||
|
||||
create(data: { name: string; description?: string; icon?: string; parentId?: string }): Location {
|
||||
const now = new Date().toISOString();
|
||||
const path = buildPath(locations, data.parentId);
|
||||
const depth = getDepth(locations, data.parentId);
|
||||
const siblings = locations.filter((l) => l.parentId === data.parentId);
|
||||
|
||||
const location: Location = {
|
||||
id: generateId(),
|
||||
parentId: data.parentId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
path,
|
||||
depth,
|
||||
order: siblings.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
locations = [...locations, location];
|
||||
saveToStorage(locations);
|
||||
return location;
|
||||
},
|
||||
|
||||
update(id: string, data: Partial<Pick<Location, 'name' | 'description' | 'icon'>>) {
|
||||
locations = locations.map((l) =>
|
||||
l.id === id ? { ...l, ...data, updatedAt: new Date().toISOString() } : l
|
||||
);
|
||||
saveToStorage(locations);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
// Delete location and all children
|
||||
const idsToDelete = new Set<string>();
|
||||
const collectIds = (parentId: string) => {
|
||||
idsToDelete.add(parentId);
|
||||
locations.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
|
||||
};
|
||||
collectIds(id);
|
||||
locations = locations.filter((l) => !idsToDelete.has(l.id));
|
||||
saveToStorage(locations);
|
||||
},
|
||||
};
|
||||
6
apps/inventar/apps/web/src/lib/stores/navigation.ts
Normal file
6
apps/inventar/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({
|
||||
withToolbar: true,
|
||||
toolbarCollapsedDefault: true,
|
||||
});
|
||||
6
apps/inventar/apps/web/src/lib/stores/theme.ts
Normal file
6
apps/inventar/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'inventar',
|
||||
defaultVariant: 'ocean',
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
105
apps/inventar/apps/web/src/lib/stores/view.svelte.ts
Normal file
105
apps/inventar/apps/web/src/lib/stores/view.svelte.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@inventar/shared';
|
||||
|
||||
const VIEW_KEY = 'inventar_view_mode';
|
||||
const SORT_KEY = 'inventar_sort';
|
||||
const FILTERS_KEY = 'inventar_saved_filters';
|
||||
|
||||
function load<T>(key: string, fallback: T): T {
|
||||
if (!browser) return fallback;
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function save(key: string, value: unknown) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
let viewMode = $state<ViewMode>('list');
|
||||
let sort = $state<SortOption>({ field: 'name', direction: 'asc' });
|
||||
let activeFilters = $state<FilterCriteria>({});
|
||||
let savedFilters = $state<SavedFilter[]>([]);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const viewStore = {
|
||||
get viewMode() {
|
||||
return viewMode;
|
||||
},
|
||||
get sort() {
|
||||
return sort;
|
||||
},
|
||||
get activeFilters() {
|
||||
return activeFilters;
|
||||
},
|
||||
get savedFilters() {
|
||||
return savedFilters;
|
||||
},
|
||||
get hasActiveFilters() {
|
||||
return !!(
|
||||
activeFilters.search ||
|
||||
activeFilters.status?.length ||
|
||||
activeFilters.locationId ||
|
||||
activeFilters.categoryId ||
|
||||
activeFilters.tagIds?.length ||
|
||||
activeFilters.collectionId
|
||||
);
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (initialized) return;
|
||||
viewMode = load<ViewMode>(VIEW_KEY, 'list');
|
||||
sort = load<SortOption>(SORT_KEY, { field: 'name', direction: 'asc' });
|
||||
savedFilters = load<SavedFilter[]>(FILTERS_KEY, []);
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
setViewMode(mode: ViewMode) {
|
||||
viewMode = mode;
|
||||
save(VIEW_KEY, mode);
|
||||
},
|
||||
|
||||
setSort(newSort: SortOption) {
|
||||
sort = newSort;
|
||||
save(SORT_KEY, newSort);
|
||||
},
|
||||
|
||||
setFilters(filters: FilterCriteria) {
|
||||
activeFilters = filters;
|
||||
},
|
||||
|
||||
updateFilter<K extends keyof FilterCriteria>(key: K, value: FilterCriteria[K]) {
|
||||
activeFilters = { ...activeFilters, [key]: value };
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
activeFilters = {};
|
||||
},
|
||||
|
||||
saveFilter(name: string) {
|
||||
const filter: SavedFilter = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
criteria: { ...activeFilters },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
savedFilters = [...savedFilters, filter];
|
||||
save(FILTERS_KEY, savedFilters);
|
||||
},
|
||||
|
||||
loadFilter(id: string) {
|
||||
const filter = savedFilters.find((f) => f.id === id);
|
||||
if (filter) {
|
||||
activeFilters = { ...filter.criteria };
|
||||
}
|
||||
},
|
||||
|
||||
deleteSavedFilter(id: string) {
|
||||
savedFilters = savedFilters.filter((f) => f.id !== id);
|
||||
save(FILTERS_KEY, savedFilters);
|
||||
},
|
||||
};
|
||||
4
apps/inventar/apps/web/src/lib/version.ts
Normal file
4
apps/inventar/apps/web/src/lib/version.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const APP_VERSION = '1.0.0';
|
||||
export const BUILD_TIME: string =
|
||||
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
|
||||
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';
|
||||
173
apps/inventar/apps/web/src/routes/(app)/+layout.svelte
Normal file
173
apps/inventar/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import { locationsStore } from '$lib/stores/locations.svelte';
|
||||
import { categoriesStore } from '$lib/stores/categories.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let showNav = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
// Initialize stores
|
||||
$effect(() => {
|
||||
if (authStore.initialized && !initialized) {
|
||||
collectionsStore.initialize();
|
||||
itemsStore.initialize();
|
||||
locationsStore.initialize();
|
||||
categoriesStore.initialize();
|
||||
viewStore.initialize();
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Auth gate
|
||||
$effect(() => {
|
||||
if (authStore.initialized && !authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Sammlungen', icon: 'archive' },
|
||||
{ href: '/items', label: 'Alle Items', icon: 'list' },
|
||||
{ href: '/locations', label: 'Standorte', icon: 'map-pin' },
|
||||
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
|
||||
{ href: '/search', label: 'Suche', icon: 'search' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/mana', label: 'Mana', icon: 'star' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
function handleLogout() {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !authStore.initialized}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if !authStore.isAuthenticated}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Weiterleitung...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<!-- Top Navigation -->
|
||||
{#if showNav}
|
||||
<nav
|
||||
class="sticky top-0 z-40 border-b border-[hsl(var(--border))] bg-[hsl(var(--background))/0.95] backdrop-blur"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-2">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-xl">📦</span>
|
||||
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Inventar</span>
|
||||
</a>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {$page.url.pathname ===
|
||||
item.href ||
|
||||
($page.url.pathname.startsWith(item.href) && item.href !== '/')
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)] hover:text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Language -->
|
||||
<select
|
||||
class="rounded-lg border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
{#each supportedLocales as loc}
|
||||
<option value={loc}>{loc.toUpperCase()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- User menu -->
|
||||
<a
|
||||
href="/profile"
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
title="Abmelden"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile nav -->
|
||||
<div class="flex gap-1 overflow-x-auto px-4 pb-2 md:hidden">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="shrink-0 rounded-full px-3 py-1 text-xs transition-colors {$page.url
|
||||
.pathname === item.href
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- FAB for mobile - New Item -->
|
||||
<button
|
||||
onclick={() => goto('/items?action=new')}
|
||||
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] shadow-lg transition-transform hover:scale-105 md:hidden"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
133
apps/inventar/apps/web/src/routes/(app)/+page.svelte
Normal file
133
apps/inventar/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import type { Collection } from '@inventar/shared';
|
||||
|
||||
function getItemCount(collectionId: string): number {
|
||||
return itemsStore.getCountByCollection(collectionId);
|
||||
}
|
||||
|
||||
function handleCollectionClick(collection: Collection) {
|
||||
goto(`/collections/${collection.id}`);
|
||||
}
|
||||
|
||||
function handleDelete(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Sammlung und alle Items löschen?')) {
|
||||
itemsStore.deleteByCollection(id);
|
||||
collectionsStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
let totalItems = $derived(itemsStore.getTotalCount());
|
||||
let totalCollections = $derived(collectionsStore.collections.length);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.collections')}</h1>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{totalCollections} Sammlungen · {totalItems} Items
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/collections/new"
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$_('collection.create')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Collections grid -->
|
||||
{#if collectionsStore.collections.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
<span class="mb-4 text-5xl">📦</span>
|
||||
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('collection.noCollections')}
|
||||
</h2>
|
||||
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
Erstelle deine erste Sammlung, um loszulegen.
|
||||
</p>
|
||||
<a
|
||||
href="/collections/new"
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
{$_('collection.create')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each collectionsStore.collections.sort((a, b) => a.order - b.order) as collection (collection.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleCollectionClick(collection)}
|
||||
class="item-card group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{collection.icon || '📁'}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[hsl(var(--foreground))]">{collection.name}</h3>
|
||||
{#if collection.description}
|
||||
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))] line-clamp-1">
|
||||
{collection.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={(e) => handleDelete(e, collection.id)}
|
||||
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{getItemCount(collection.id)} Items
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
{#each collection.schema.fields.slice(0, 3) as field}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{field.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if collection.schema.fields.length > 3}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
+{collection.schema.fields.length - 3}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
143
apps/inventar/apps/web/src/routes/(app)/categories/+page.svelte
Normal file
143
apps/inventar/apps/web/src/routes/(app)/categories/+page.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { categoriesStore } from '$lib/stores/categories.svelte';
|
||||
import type { Category } from '@inventar/shared';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let name = $state('');
|
||||
let icon = $state('');
|
||||
let color = $state('');
|
||||
|
||||
function startCreate() {
|
||||
name = '';
|
||||
icon = '';
|
||||
color = '';
|
||||
editingId = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function startEdit(category: Category) {
|
||||
editingId = category.id;
|
||||
name = category.name;
|
||||
icon = category.icon || '';
|
||||
color = category.color || '';
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!name.trim()) return;
|
||||
if (editingId) {
|
||||
categoriesStore.update(editingId, {
|
||||
name: name.trim(),
|
||||
icon: icon || undefined,
|
||||
color: color || undefined,
|
||||
});
|
||||
} else {
|
||||
categoriesStore.create({
|
||||
name: name.trim(),
|
||||
icon: icon || undefined,
|
||||
color: color || undefined,
|
||||
});
|
||||
}
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function deleteCategory(id: string) {
|
||||
if (confirm('Kategorie löschen?')) {
|
||||
categoriesStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.categories')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.categories')}</h1>
|
||||
<button
|
||||
onclick={startCreate}
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$_('category.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={icon}
|
||||
placeholder="🏷️"
|
||||
class="{inputClass} w-12 text-center text-lg"
|
||||
maxlength="2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$_('category.name')}
|
||||
class="{inputClass} flex-1"
|
||||
onkeydown={(e) => e.key === 'Enter' && save()}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={color}
|
||||
class="h-9 w-9 cursor-pointer rounded-lg border border-[hsl(var(--border))]"
|
||||
/>
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={!name.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>{$_('common.save')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => (showForm = false)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm"
|
||||
>{$_('common.cancel')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if categoriesStore.categories.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
<span class="mb-4 text-4xl">🏷️</span>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">{$_('category.noCategories')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each categoriesStore.categories.sort((a, b) => a.order - b.order) as category (category.id)}
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
|
||||
>
|
||||
<span class="text-xl">{category.icon || '🏷️'}</span>
|
||||
{#if category.color}
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {category.color}"></span>
|
||||
{/if}
|
||||
<span class="flex-1 font-medium text-[hsl(var(--foreground))]">{category.name}</span>
|
||||
<button
|
||||
onclick={() => startEdit(category)}
|
||||
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-[hsl(var(--foreground))] group-hover:opacity-100"
|
||||
>✎</button
|
||||
>
|
||||
<button
|
||||
onclick={() => deleteCategory(category.id)}
|
||||
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { locationsStore } from '$lib/stores/locations.svelte';
|
||||
import { categoriesStore } from '$lib/stores/categories.svelte';
|
||||
import type { Item, ItemStatus } from '@inventar/shared';
|
||||
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
|
||||
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||
|
||||
let collectionId = $derived($page.params.id);
|
||||
let collection = $derived(collectionsStore.getById(collectionId));
|
||||
let items = $derived(itemsStore.getByCollection(collectionId));
|
||||
let sortedItems = $derived(itemsStore.getSorted(items, viewStore.sort));
|
||||
|
||||
// Item creation
|
||||
let showNewItem = $state(false);
|
||||
let newItemName = $state('');
|
||||
let newItemFields = $state<Record<string, unknown>>({});
|
||||
let newItemStatus = $state<ItemStatus>('owned');
|
||||
|
||||
function createItem() {
|
||||
if (!newItemName.trim() || !collection) return;
|
||||
itemsStore.create({
|
||||
collectionId: collection.id,
|
||||
name: newItemName.trim(),
|
||||
status: newItemStatus,
|
||||
fieldValues: newItemFields,
|
||||
});
|
||||
newItemName = '';
|
||||
newItemFields = {};
|
||||
newItemStatus = 'owned';
|
||||
showNewItem = false;
|
||||
}
|
||||
|
||||
function deleteItem(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Item löschen?')) {
|
||||
itemsStore.delete(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{collection?.name || 'Sammlung'} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !collection}
|
||||
<div class="text-center py-16">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
|
||||
<a href="/" class="mt-4 text-[hsl(var(--primary))]">Zurück</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-2xl">{collection.icon || '📁'}</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{collection.name}</h1>
|
||||
{#if collection.description}
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">{collection.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ViewModeToggle current={viewStore.viewMode} onchange={(m) => viewStore.setViewMode(m)} />
|
||||
<a
|
||||
href="/collections/{collection.id}/edit"
|
||||
class="rounded-lg border border-[hsl(var(--border))] p-2 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onclick={() => (showNewItem = !showNewItem)}
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('item.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Item Form -->
|
||||
{#if showNewItem}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newItemName}
|
||||
placeholder={$_('item.name')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
|
||||
onkeydown={(e) => e.key === 'Enter' && createItem()}
|
||||
/>
|
||||
|
||||
<!-- Custom fields -->
|
||||
{#if collection.schema.fields.length > 0}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{field.name}{field.required ? ' *' : ''}
|
||||
</label>
|
||||
<FieldEditor
|
||||
{field}
|
||||
value={newItemFields[field.id]}
|
||||
onchange={(v) => (newItemFields = { ...newItemFields, [field.id]: v })}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => (showNewItem = false)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={createItem}
|
||||
disabled={!newItemName.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Items -->
|
||||
{#if sortedItems.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
<span class="mb-4 text-4xl">📭</span>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">{$_('item.noItems')}</p>
|
||||
<button
|
||||
onclick={() => (showNewItem = true)}
|
||||
class="mt-4 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
{$_('item.create')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if viewStore.viewMode === 'grid'}
|
||||
<!-- Grid View -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sortedItems as item (item.id)}
|
||||
<button
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
class="item-card group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-semibold text-[hsl(var(--foreground))]">{item.name}</h3>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
{#if collection.schema.fields.length > 0}
|
||||
<div class="mt-3 space-y-1">
|
||||
{#each collection.schema.fields.slice(0, 3) as field}
|
||||
{#if item.fieldValues[field.id] !== undefined}
|
||||
<div class="flex items-baseline gap-2 text-xs">
|
||||
<span class="text-[hsl(var(--muted-foreground))]">{field.name}:</span>
|
||||
<FieldRenderer {field} value={item.fieldValues[field.id]} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => deleteItem(e, item.id)}
|
||||
class="mt-2 text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
{$_('common.delete')}
|
||||
</button>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if viewStore.viewMode === 'table'}
|
||||
<!-- Table View -->
|
||||
<div class="overflow-x-auto rounded-xl border border-[hsl(var(--border))]">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[hsl(var(--border))] bg-[hsl(var(--muted))]">
|
||||
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.name')}</th
|
||||
>
|
||||
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.status')}</th
|
||||
>
|
||||
{#each collection.schema.fields as field}
|
||||
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{field.name}</th
|
||||
>
|
||||
{/each}
|
||||
<th class="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedItems as item (item.id)}
|
||||
<tr
|
||||
class="cursor-pointer border-b border-[hsl(var(--border))] transition-colors hover:bg-[hsl(var(--accent)/0.05)]"
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
>
|
||||
<td class="px-4 py-2.5 font-medium text-[hsl(var(--foreground))]">{item.name}</td>
|
||||
<td class="px-4 py-2.5"><StatusBadge status={item.status} /></td>
|
||||
{#each collection.schema.fields as field}
|
||||
<td class="px-4 py-2.5"
|
||||
><FieldRenderer {field} value={item.fieldValues[field.id]} /></td
|
||||
>
|
||||
{/each}
|
||||
<td class="px-4 py-2.5">
|
||||
<button
|
||||
onclick={(e) => deleteItem(e, item.id)}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- List View -->
|
||||
<div class="space-y-2">
|
||||
{#each sortedItems as item (item.id)}
|
||||
<button
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
class="group flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-[hsl(var(--foreground))] truncate">{item.name}</h3>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
{#if collection.schema.fields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-3 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{#each collection.schema.fields.slice(0, 4) as field}
|
||||
{#if item.fieldValues[field.id] !== undefined}
|
||||
<span
|
||||
>{field.name}: <FieldRenderer
|
||||
{field}
|
||||
value={item.fieldValues[field.id]}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.quantity > 1}
|
||||
<span class="text-sm text-[hsl(var(--muted-foreground))]">×{item.quantity}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => deleteItem(e, item.id)}
|
||||
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import type { CollectionSchema } from '@inventar/shared';
|
||||
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
|
||||
|
||||
let collectionId = $derived($page.params.id);
|
||||
let collection = $derived(collectionsStore.getById(collectionId));
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let icon = $state('');
|
||||
let schema = $state<CollectionSchema>({ fields: [] });
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (collection && !loaded) {
|
||||
name = collection.name;
|
||||
description = collection.description || '';
|
||||
icon = collection.icon || '';
|
||||
schema = { fields: [...collection.schema.fields] };
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
if (!collection || !name.trim()) return;
|
||||
collectionsStore.update(collection.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
icon: icon || undefined,
|
||||
schema,
|
||||
});
|
||||
goto(`/collections/${collection.id}`);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('collection.edit')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !collection}
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => goto(`/collections/${collection.id}`)}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('collection.edit')}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={icon}
|
||||
placeholder="📁"
|
||||
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
|
||||
maxlength="2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$_('collection.name')}
|
||||
class="{inputClass} flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={description}
|
||||
placeholder={$_('collection.description')}
|
||||
rows="2"
|
||||
class={inputClass}
|
||||
></textarea>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('collection.customFields')}
|
||||
</h3>
|
||||
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
onclick={() => goto(`/collections/${collection.id}`)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { DEFAULT_TEMPLATES } from '@inventar/shared';
|
||||
import type { CollectionSchema, Template } from '@inventar/shared';
|
||||
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
|
||||
|
||||
let step = $state<'template' | 'details'>('template');
|
||||
let selectedTemplate = $state<Template | null>(null);
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let icon = $state('');
|
||||
let schema = $state<CollectionSchema>({ fields: [] });
|
||||
|
||||
function selectTemplate(template: Template) {
|
||||
selectedTemplate = template;
|
||||
if (template.id !== 'custom') {
|
||||
name = template.name;
|
||||
icon = template.icon;
|
||||
}
|
||||
schema = { fields: [...template.schema.fields] };
|
||||
step = 'details';
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
collectionsStore.create({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
icon: icon || undefined,
|
||||
schema,
|
||||
templateId: selectedTemplate?.id,
|
||||
});
|
||||
goto('/');
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('collection.create')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => (step === 'details' && selectedTemplate ? (step = 'template') : goto('/'))}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('collection.create')}</h1>
|
||||
</div>
|
||||
|
||||
{#if step === 'template'}
|
||||
<!-- Template Selection -->
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('collection.selectTemplate')}
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each DEFAULT_TEMPLATES as template}
|
||||
<button
|
||||
onclick={() => selectTemplate(template)}
|
||||
class="item-card rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{template.icon}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[hsl(var(--foreground))]">{template.name}</h3>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{template.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if template.schema.fields.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
{#each template.schema.fields as field}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{field.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collection Details -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={icon}
|
||||
placeholder="📁"
|
||||
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
|
||||
maxlength="2"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$_('collection.name')}
|
||||
class="{inputClass} flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={description}
|
||||
placeholder={$_('collection.description')}
|
||||
rows="2"
|
||||
class={inputClass}
|
||||
></textarea>
|
||||
|
||||
<!-- Schema Editor -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('collection.customFields')}
|
||||
</h3>
|
||||
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCreate}
|
||||
disabled={!name.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { feedbackService } from '$lib/services/feedback';
|
||||
</script>
|
||||
|
||||
<FeedbackPage {feedbackService} user={authStore.user} appName="Inventar" />
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { HelpPage } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<HelpPage appName="Inventar" defaultSection="faq" />
|
||||
147
apps/inventar/apps/web/src/routes/(app)/items/+page.svelte
Normal file
147
apps/inventar/apps/web/src/routes/(app)/items/+page.svelte
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||
import type { ItemStatus } from '@inventar/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredItems = $derived(
|
||||
itemsStore.getFiltered({
|
||||
search: searchQuery || undefined,
|
||||
...viewStore.activeFilters,
|
||||
})
|
||||
);
|
||||
let sortedItems = $derived(itemsStore.getSorted(filteredItems, viewStore.sort));
|
||||
|
||||
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
|
||||
|
||||
function toggleStatus(status: ItemStatus) {
|
||||
const current = viewStore.activeFilters.status || [];
|
||||
if (current.includes(status)) {
|
||||
viewStore.updateFilter(
|
||||
'status',
|
||||
current.filter((s) => s !== status)
|
||||
);
|
||||
} else {
|
||||
viewStore.updateFilter('status', [...current, status]);
|
||||
}
|
||||
}
|
||||
|
||||
function getCollectionName(collectionId: string): string {
|
||||
return collectionsStore.getById(collectionId)?.name || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.allItems')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.allItems')}</h1>
|
||||
<ViewModeToggle current={viewStore.viewMode} onchange={(m) => viewStore.setViewMode(m)} />
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="{$_('common.search')}..."
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-2.5 pl-10 pr-4 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status filter chips -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each statuses as status}
|
||||
<button
|
||||
onclick={() => toggleStatus(status)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {(
|
||||
viewStore.activeFilters.status || []
|
||||
).includes(status)
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.2)]'}"
|
||||
>
|
||||
{$_(`status.${status}`)}
|
||||
</button>
|
||||
{/each}
|
||||
{#if viewStore.hasActiveFilters}
|
||||
<button
|
||||
onclick={() => viewStore.clearFilters()}
|
||||
class="rounded-full px-3 py-1 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
{$_('filter.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">{sortedItems.length} Items</p>
|
||||
|
||||
{#if sortedItems.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<span class="mb-4 text-4xl">🔍</span>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">{$_('common.noResults')}</p>
|
||||
</div>
|
||||
{:else if viewStore.viewMode === 'grid'}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sortedItems as item (item.id)}
|
||||
<button
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
class="item-card rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-semibold text-[hsl(var(--foreground))]">{item.name}</h3>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{getCollectionName(item.collectionId)}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each sortedItems as item (item.id)}
|
||||
<button
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
class="flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-[hsl(var(--foreground))] truncate">{item.name}</h3>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{getCollectionName(item.collectionId)}
|
||||
</p>
|
||||
</div>
|
||||
{#if item.quantity > 1}
|
||||
<span class="text-sm text-[hsl(var(--muted-foreground))]">×{item.quantity}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
311
apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte
Normal file
311
apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { locationsStore } from '$lib/stores/locations.svelte';
|
||||
import { categoriesStore } from '$lib/stores/categories.svelte';
|
||||
import type { ItemStatus } from '@inventar/shared';
|
||||
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
|
||||
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
|
||||
let itemId = $derived($page.params.id);
|
||||
let item = $derived(itemsStore.getById(itemId));
|
||||
let collection = $derived(item ? collectionsStore.getById(item.collectionId) : undefined);
|
||||
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editStatus = $state<ItemStatus>('owned');
|
||||
let editQuantity = $state(1);
|
||||
let editFields = $state<Record<string, unknown>>({});
|
||||
let editLocationId = $state<string | undefined>();
|
||||
let editCategoryId = $state<string | undefined>();
|
||||
|
||||
// Notes
|
||||
let newNote = $state('');
|
||||
|
||||
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
|
||||
|
||||
function startEditing() {
|
||||
if (!item) return;
|
||||
editName = item.name;
|
||||
editDescription = item.description || '';
|
||||
editStatus = item.status;
|
||||
editQuantity = item.quantity;
|
||||
editFields = { ...item.fieldValues };
|
||||
editLocationId = item.locationId;
|
||||
editCategoryId = item.categoryId;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!item || !editName.trim()) return;
|
||||
itemsStore.update(item.id, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
status: editStatus,
|
||||
quantity: editQuantity,
|
||||
fieldValues: editFields,
|
||||
locationId: editLocationId,
|
||||
categoryId: editCategoryId,
|
||||
});
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
if (!item || !newNote.trim()) return;
|
||||
itemsStore.addNote(item.id, newNote.trim());
|
||||
newNote = '';
|
||||
}
|
||||
|
||||
function deleteItem() {
|
||||
if (!item || !confirm('Item endgültig löschen?')) return;
|
||||
itemsStore.delete(item.id);
|
||||
goto(collection ? `/collections/${collection.id}` : '/items');
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{item?.name || 'Item'} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !item}
|
||||
<div class="text-center py-16">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Item nicht gefunden</p>
|
||||
<a href="/items" class="mt-4 text-[hsl(var(--primary))]">Zurück</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => goto(collection ? `/collections/${collection.id}` : '/items')}
|
||||
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if !editing}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{item.name}</h1>
|
||||
{#if collection}
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{collection.icon}
|
||||
{collection.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if editing}
|
||||
<button
|
||||
onclick={() => (editing = false)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
|
||||
>{$_('common.cancel')}</button
|
||||
>
|
||||
<button
|
||||
onclick={saveEdit}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>{$_('common.save')}</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
onclick={startEditing}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('common.edit')}</button
|
||||
>
|
||||
<button
|
||||
onclick={deleteItem}
|
||||
class="rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
|
||||
>{$_('common.delete')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editing}
|
||||
<!-- Edit Mode -->
|
||||
<div
|
||||
class="space-y-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5"
|
||||
>
|
||||
<input type="text" bind:value={editName} placeholder={$_('item.name')} class={inputClass} />
|
||||
<textarea
|
||||
bind:value={editDescription}
|
||||
placeholder={$_('item.description')}
|
||||
rows="2"
|
||||
class={inputClass}
|
||||
></textarea>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.status')}</label
|
||||
>
|
||||
<select bind:value={editStatus} class={inputClass}>
|
||||
{#each statuses as s}<option value={s}>{$_(`status.${s}`)}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.quantity')}</label
|
||||
>
|
||||
<input type="number" bind:value={editQuantity} min="1" class={inputClass} />
|
||||
</div>
|
||||
{#if locationsStore.locations.length > 0}
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.location')}</label
|
||||
>
|
||||
<select bind:value={editLocationId} class={inputClass}>
|
||||
<option value={undefined}>— Kein Standort —</option>
|
||||
{#each locationsStore.locations as loc}
|
||||
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{#if categoriesStore.categories.length > 0}
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('item.category')}</label
|
||||
>
|
||||
<select bind:value={editCategoryId} class={inputClass}>
|
||||
<option value={undefined}>— Keine Kategorie —</option>
|
||||
{#each categoriesStore.categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if collection}
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('collection.customFields')}
|
||||
</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{field.name}</label
|
||||
>
|
||||
<FieldEditor
|
||||
{field}
|
||||
value={editFields[field.id]}
|
||||
onchange={(v) => (editFields = { ...editFields, [field.id]: v })}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="space-y-4">
|
||||
<!-- Status & Meta -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge status={item.status} size="md" />
|
||||
{#if item.quantity > 1}
|
||||
<span class="rounded-full bg-[hsl(var(--muted))] px-3 py-1 text-sm"
|
||||
>×{item.quantity}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.locationId}
|
||||
{@const loc = locationsStore.getById(item.locationId)}
|
||||
{#if loc}
|
||||
<span class="flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
📍 {locationsStore.getFullPath(loc.id)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.categoryId}
|
||||
{@const cat = categoriesStore.getById(item.categoryId)}
|
||||
{#if cat}
|
||||
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
|
||||
>{cat.icon || '🏷️'} {cat.name}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if item.description}
|
||||
<p class="text-[hsl(var(--foreground))]">{item.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Custom Fields -->
|
||||
{#if collection && collection.schema.fields.length > 0}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">Details</h3>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]"
|
||||
>{field.name}:</span
|
||||
>
|
||||
<FieldRenderer {field} value={item.fieldValues[field.id]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('item.notes')} ({item.notes.length})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each item.notes as note (note.id)}
|
||||
<div
|
||||
class="group flex items-start justify-between rounded-lg bg-[hsl(var(--muted))] p-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-[hsl(var(--foreground))]">{note.content}</p>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{new Date(note.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => itemsStore.deleteNote(item.id, note.id)}
|
||||
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newNote}
|
||||
placeholder="Notiz hinzufügen..."
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
onkeydown={(e) => e.key === 'Enter' && addNote()}
|
||||
/>
|
||||
<button
|
||||
onclick={addNote}
|
||||
disabled={!newNote.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-2 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>+</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
153
apps/inventar/apps/web/src/routes/(app)/locations/+page.svelte
Normal file
153
apps/inventar/apps/web/src/routes/(app)/locations/+page.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { locationsStore } from '$lib/stores/locations.svelte';
|
||||
import type { Location } from '@inventar/shared';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let parentId = $state<string | undefined>();
|
||||
let name = $state('');
|
||||
let icon = $state('');
|
||||
|
||||
function startCreate(parent?: string) {
|
||||
parentId = parent;
|
||||
name = '';
|
||||
icon = '';
|
||||
editingId = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function startEdit(location: Location) {
|
||||
editingId = location.id;
|
||||
name = location.name;
|
||||
icon = location.icon || '';
|
||||
parentId = location.parentId;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!name.trim()) return;
|
||||
if (editingId) {
|
||||
locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
|
||||
} else {
|
||||
locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
|
||||
}
|
||||
showForm = false;
|
||||
name = '';
|
||||
icon = '';
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function deleteLocation(id: string) {
|
||||
if (confirm('Standort und alle Unterstandorte löschen?')) {
|
||||
locationsStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
let tree = $derived(locationsStore.getTree());
|
||||
|
||||
const inputClass =
|
||||
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.locations')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.locations')}</h1>
|
||||
<button
|
||||
onclick={() => startCreate()}
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$_('location.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold">
|
||||
{editingId ? $_('location.edit') : $_('location.create')}
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={icon}
|
||||
placeholder="📍"
|
||||
class="{inputClass} w-12 text-center text-lg"
|
||||
maxlength="2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$_('location.name')}
|
||||
class="{inputClass} flex-1"
|
||||
onkeydown={(e) => e.key === 'Enter' && save()}
|
||||
/>
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={!name.trim()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showForm = false)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tree.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
<span class="mb-4 text-4xl">📍</span>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">{$_('location.noLocations')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
|
||||
{#snippet renderTree(locations: Location[], depth: number)}
|
||||
{#each locations as location (location.id)}
|
||||
<div class="location-tree-item border-b border-[hsl(var(--border))] last:border-b-0">
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2.5"
|
||||
style="padding-left: {16 + depth * 24}px"
|
||||
>
|
||||
<span class="text-lg">{location.icon || '📍'}</span>
|
||||
<span class="flex-1 text-sm font-medium text-[hsl(var(--foreground))]"
|
||||
>{location.name}</span
|
||||
>
|
||||
<button
|
||||
onclick={() => startCreate(location.id)}
|
||||
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
|
||||
title={$_('location.addSub')}>+</button
|
||||
>
|
||||
<button
|
||||
onclick={() => startEdit(location)}
|
||||
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>✎</button
|
||||
>
|
||||
<button
|
||||
onclick={() => deleteLocation(location.id)}
|
||||
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{#if location.children?.length}
|
||||
{@render renderTree(location.children, depth + 1)}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
{@render renderTree(tree, 0)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
39
apps/inventar/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
39
apps/inventar/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
|
||||
|
||||
function handleSubscribe(planId: string) {
|
||||
console.log('Subscribe to plan:', planId);
|
||||
alert(`Abo "${planId}" ausgewählt. Bezahlsystem wird noch integriert.`);
|
||||
}
|
||||
|
||||
function handleBuyPackage(packageId: string) {
|
||||
console.log('Buy package:', packageId);
|
||||
alert(`Paket "${packageId}" ausgewählt. Bezahlsystem wird noch integriert.`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mana - Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mana-page">
|
||||
<SubscriptionPage
|
||||
appName="Inventar"
|
||||
onSubscribe={handleSubscribe}
|
||||
onBuyPackage={handleBuyPackage}
|
||||
currentPlanId="free"
|
||||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mana-page {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { ProfilePage } from '@manacore/shared-profile-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<ProfilePage user={authStore.user} appName="Inventar" />
|
||||
65
apps/inventar/apps/web/src/routes/(app)/search/+page.svelte
Normal file
65
apps/inventar/apps/web/src/routes/(app)/search/+page.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results = $derived(query.length >= 2 ? itemsStore.getFiltered({ search: query }) : []);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.search')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.search')}</h1>
|
||||
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="{$_('common.search')} in allen Items..."
|
||||
class="w-full rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-3 pl-11 pr-4 text-lg text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if query.length >= 2}
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">{results.length} Ergebnisse</p>
|
||||
<div class="space-y-2">
|
||||
{#each results as item (item.id)}
|
||||
<button
|
||||
onclick={() => goto(`/items/${item.id}`)}
|
||||
class="flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-[hsl(var(--foreground))]">{item.name}</h3>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{collectionsStore.getById(item.collectionId)?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if query.length > 0}
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">Mindestens 2 Zeichen eingeben...</p>
|
||||
{/if}
|
||||
</div>
|
||||
122
apps/inventar/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
122
apps/inventar/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
import {
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
SettingsCard,
|
||||
SettingsRow,
|
||||
SettingsDangerZone,
|
||||
SettingsDangerButton,
|
||||
GlobalSettingsSection,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Einstellungen | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<SettingsPage title="Einstellungen" subtitle="Verwalte dein Konto und passe die App an.">
|
||||
<SettingsSection title="Konto">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
label="E-Mail"
|
||||
description={authStore.user?.email || 'Nicht angemeldet'}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<GlobalSettingsSection
|
||||
{userSettings}
|
||||
appId="inventar"
|
||||
navItems={[
|
||||
{ href: '/', label: 'Sammlungen', icon: 'archive' },
|
||||
{ href: '/items', label: 'Alle Items', icon: 'list' },
|
||||
{ href: '/locations', label: 'Standorte', icon: 'map-pin' },
|
||||
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
|
||||
{ href: '/search', label: 'Suche', icon: 'search' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
]}
|
||||
alwaysVisibleHrefs={['/', '/settings']}
|
||||
/>
|
||||
|
||||
<SettingsSection title="Über">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<SettingsCard>
|
||||
<SettingsRow label="Version" border={false}>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<span class="text-[hsl(var(--muted-foreground))]">{APP_VERSION}</span>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsDangerZone title="Gefahrenzone">
|
||||
<SettingsDangerButton
|
||||
label="Abmelden"
|
||||
description="Von deinem Konto abmelden"
|
||||
buttonText="Abmelden"
|
||||
onclick={handleLogout}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsDangerButton>
|
||||
</SettingsDangerZone>
|
||||
|
||||
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
|
||||
</SettingsPage>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { ThemePage } from '@manacore/shared-theme';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
</script>
|
||||
|
||||
<ThemePage {theme} appName="Inventar" />
|
||||
5
apps/inventar/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/inventar/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
263
apps/inventar/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
263
apps/inventar/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let showRegister = $state(false);
|
||||
let showForgotPassword = $state(false);
|
||||
let show2FA = $state(false);
|
||||
let twoFactorCode = $state('');
|
||||
let verificationSent = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) return;
|
||||
isLoading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await authStore.signIn(email, password);
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else if (result.error?.includes('two-factor') || result.error?.includes('2FA')) {
|
||||
show2FA = true;
|
||||
} else {
|
||||
error = result.error || 'Login fehlgeschlagen';
|
||||
}
|
||||
} catch {
|
||||
error = 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!email || !password) return;
|
||||
isLoading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await authStore.signUp(email, password);
|
||||
if (result.success && !result.needsVerification) {
|
||||
goto('/');
|
||||
} else if (result.needsVerification) {
|
||||
verificationSent = true;
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
}
|
||||
} catch {
|
||||
error = 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForgotPassword() {
|
||||
if (!email) {
|
||||
error = 'Bitte E-Mail eingeben';
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
error = '';
|
||||
const result = await authStore.resetPassword(email);
|
||||
if (result.success) {
|
||||
verificationSent = true;
|
||||
} else {
|
||||
error = result.error || 'Fehler beim Zurücksetzen';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handle2FA() {
|
||||
if (!twoFactorCode) return;
|
||||
isLoading = true;
|
||||
const result = await authStore.verifyTwoFactor(twoFactorCode, true);
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
error = 'Ungültiger Code';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handlePasskey() {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
const result = await authStore.signInWithPasskey();
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
error = result.error || 'Passkey fehlgeschlagen';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary))]"
|
||||
>
|
||||
<span class="text-3xl">📦</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Inventar</h1>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Inventarverwaltung</p>
|
||||
</div>
|
||||
|
||||
{#if verificationSent}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 text-center"
|
||||
>
|
||||
<p class="text-[hsl(var(--foreground))]">
|
||||
{showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">Bitte prüfe dein Postfach.</p>
|
||||
<button
|
||||
onclick={() => {
|
||||
verificationSent = false;
|
||||
showForgotPassword = false;
|
||||
showRegister = false;
|
||||
}}
|
||||
class="mt-4 text-sm text-[hsl(var(--primary))]"
|
||||
>
|
||||
Zurück zum Login
|
||||
</button>
|
||||
</div>
|
||||
{:else if show2FA}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={twoFactorCode}
|
||||
placeholder="Code eingeben"
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
onkeydown={(e) => e.key === 'Enter' && handle2FA()}
|
||||
/>
|
||||
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
|
||||
<button
|
||||
onclick={handle2FA}
|
||||
disabled={isLoading}
|
||||
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Prüfe...' : 'Bestätigen'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
{showForgotPassword
|
||||
? 'Passwort zurücksetzen'
|
||||
: showRegister
|
||||
? $_('auth.register')
|
||||
: $_('auth.login')}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={$_('auth.email')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
{#if !showForgotPassword}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder={$_('auth.password')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
|
||||
|
||||
<button
|
||||
onclick={showForgotPassword
|
||||
? handleForgotPassword
|
||||
: showRegister
|
||||
? handleRegister
|
||||
: handleLogin}
|
||||
disabled={isLoading}
|
||||
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{isLoading
|
||||
? $_('common.loading')
|
||||
: showForgotPassword
|
||||
? 'Link senden'
|
||||
: showRegister
|
||||
? $_('auth.register')
|
||||
: $_('auth.login')}
|
||||
</button>
|
||||
|
||||
{#if !showForgotPassword && authStore.isPasskeyAvailable()}
|
||||
<button
|
||||
onclick={handlePasskey}
|
||||
disabled={isLoading}
|
||||
class="mt-2 w-full rounded-lg border border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
🔑 Mit Passkey anmelden
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
{#if showForgotPassword}
|
||||
<button
|
||||
onclick={() => {
|
||||
showForgotPassword = false;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--primary))]"
|
||||
>
|
||||
Zurück zum Login
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => {
|
||||
showForgotPassword = true;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{$_('auth.forgotPassword')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
showRegister = !showRegister;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--primary))]"
|
||||
>
|
||||
{showRegister ? $_('auth.login') : $_('auth.register')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- App switcher -->
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-2">
|
||||
{#each getPillAppItems() as app}
|
||||
{#if app.id !== 'inventar'}
|
||||
<a
|
||||
href={app.url}
|
||||
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
|
||||
>
|
||||
{app.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
19
apps/inventar/apps/web/src/routes/+error.svelte
Normal file
19
apps/inventar/apps/web/src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-6xl font-bold text-[hsl(var(--primary))]">{$page.status}</h1>
|
||||
<p class="mb-8 text-lg text-[hsl(var(--muted-foreground))]">
|
||||
{$page.error?.message || $_('error.notFound')}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
{$_('error.backToHome')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
35
apps/inventar/apps/web/src/routes/+layout.svelte
Normal file
35
apps/inventar/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await waitLocale();
|
||||
theme.initialize();
|
||||
await authStore.initialize();
|
||||
ready = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))]">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
|
||||
></div>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
10
apps/inventar/apps/web/src/routes/health/+server.ts
Normal file
10
apps/inventar/apps/web/src/routes/health/+server.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'inventar-web',
|
||||
});
|
||||
};
|
||||
27
apps/inventar/apps/web/src/routes/offline/+page.svelte
Normal file
27
apps/inventar/apps/web/src/routes/offline/+page.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Offline | Inventar</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-[hsl(var(--muted))]"
|
||||
>
|
||||
<span class="text-4xl">📦</span>
|
||||
</div>
|
||||
<h1 class="mb-2 text-2xl font-bold text-[hsl(var(--foreground))]">Offline</h1>
|
||||
<p class="mb-6 text-[hsl(var(--muted-foreground))]">
|
||||
Du bist gerade nicht mit dem Internet verbunden.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => window.location.reload()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
1
apps/inventar/apps/web/src/routes/offline/+page.ts
Normal file
1
apps/inventar/apps/web/src/routes/offline/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
||||
6
apps/inventar/apps/web/static/favicon.svg
Normal file
6
apps/inventar/apps/web/static/favicon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#f59e0b"/>
|
||||
<rect x="8" y="8" width="16" height="16" rx="2" stroke="white" stroke-width="1.5" fill="none"/>
|
||||
<path d="M8 12h16" stroke="white" stroke-width="1"/>
|
||||
<path d="M10 15h12M10 18h12M10 21h8" stroke="white" stroke-width="1" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
6
apps/inventar/apps/web/static/icons/icon.svg
Normal file
6
apps/inventar/apps/web/static/icons/icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" fill="none">
|
||||
<rect width="96" height="96" rx="20" fill="#f59e0b"/>
|
||||
<path d="M28 38h40v4H28zm0 8h40v4H28zm0 8h30v4H28z" fill="white" opacity="0.9"/>
|
||||
<rect x="24" y="24" width="48" height="48" rx="6" stroke="white" stroke-width="3" fill="none"/>
|
||||
<path d="M24 36h48" stroke="white" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
14
apps/inventar/apps/web/svelte.config.js
Normal file
14
apps/inventar/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/inventar/apps/web/tsconfig.json
Normal file
14
apps/inventar/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
64
apps/inventar/apps/web/vite.config.ts
Normal file
64
apps/inventar/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { defineConfig } from 'vite';
|
||||
import { getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Inventar',
|
||||
short_name: 'Inventar',
|
||||
description: 'Konfigurierbare Inventarverwaltung',
|
||||
theme_color: '#f59e0b',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Neues Item',
|
||||
short_name: 'Neues Item',
|
||||
url: '/items?action=new',
|
||||
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
|
||||
},
|
||||
{
|
||||
name: 'Sammlungen',
|
||||
short_name: 'Sammlungen',
|
||||
url: '/collections',
|
||||
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5190,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
port: 5190,
|
||||
},
|
||||
define: getBuildDefines(),
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
14
apps/inventar/package.json
Normal file
14
apps/inventar/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "inventar",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Inventar - Configurable Inventory Management",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @inventar/web dev",
|
||||
"dev:web": "pnpm --filter @inventar/web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
22
apps/inventar/packages/shared/package.json
Normal file
22
apps/inventar/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@inventar/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./constants": "./src/constants/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
199
apps/inventar/packages/shared/src/constants/index.ts
Normal file
199
apps/inventar/packages/shared/src/constants/index.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import type { Template, ItemStatus } from '../types/index.js';
|
||||
|
||||
export const ITEM_STATUSES: {
|
||||
value: ItemStatus;
|
||||
labelDe: string;
|
||||
labelEn: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{ value: 'owned', labelDe: 'Besitzt', labelEn: 'Owned', color: '#22c55e' },
|
||||
{ value: 'lent', labelDe: 'Verliehen', labelEn: 'Lent', color: '#f59e0b' },
|
||||
{ value: 'stored', labelDe: 'Eingelagert', labelEn: 'Stored', color: '#3b82f6' },
|
||||
{ value: 'for_sale', labelDe: 'Zu verkaufen', labelEn: 'For Sale', color: '#a855f7' },
|
||||
{ value: 'disposed', labelDe: 'Entsorgt', labelEn: 'Disposed', color: '#6b7280' },
|
||||
];
|
||||
|
||||
export const DEFAULT_TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'electronics',
|
||||
name: 'Elektronik',
|
||||
description: 'Computer, Smartphones, Gadgets',
|
||||
icon: '💻',
|
||||
category: 'tech',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
|
||||
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
|
||||
{ id: 'serial_number', name: 'Seriennummer', type: 'text', order: 2 },
|
||||
{ id: 'purchase_date', name: 'Kaufdatum', type: 'date', order: 3 },
|
||||
{ id: 'warranty_until', name: 'Garantie bis', type: 'date', order: 4 },
|
||||
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Zustand',
|
||||
type: 'select',
|
||||
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Defekt'],
|
||||
order: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'books',
|
||||
name: 'Bücher',
|
||||
description: 'Bücher, E-Books, Hörbücher',
|
||||
icon: '📚',
|
||||
category: 'media',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'author', name: 'Autor', type: 'text', order: 0 },
|
||||
{ id: 'isbn', name: 'ISBN', type: 'text', order: 1 },
|
||||
{ id: 'publisher', name: 'Verlag', type: 'text', order: 2 },
|
||||
{ id: 'genre', name: 'Genre', type: 'text', order: 3 },
|
||||
{ id: 'pages', name: 'Seiten', type: 'number', order: 4 },
|
||||
{ id: 'read', name: 'Gelesen', type: 'checkbox', order: 5 },
|
||||
{
|
||||
id: 'rating',
|
||||
name: 'Bewertung',
|
||||
type: 'select',
|
||||
options: ['1', '2', '3', '4', '5'],
|
||||
order: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'furniture',
|
||||
name: 'Möbel',
|
||||
description: 'Tische, Stühle, Regale',
|
||||
icon: '🪑',
|
||||
category: 'home',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'material', name: 'Material', type: 'text', order: 0 },
|
||||
{ id: 'dimensions', name: 'Maße', type: 'text', placeholder: 'B x H x T in cm', order: 1 },
|
||||
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
|
||||
{ id: 'room', name: 'Raum', type: 'text', order: 3 },
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Zustand',
|
||||
type: 'select',
|
||||
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Reparaturbedürftig'],
|
||||
order: 4,
|
||||
},
|
||||
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clothing',
|
||||
name: 'Kleidung',
|
||||
description: 'Kleidung, Schuhe, Accessoires',
|
||||
icon: '👕',
|
||||
category: 'fashion',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
|
||||
{ id: 'size', name: 'Größe', type: 'text', order: 1 },
|
||||
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
|
||||
{ id: 'material', name: 'Material', type: 'text', order: 3 },
|
||||
{
|
||||
id: 'season',
|
||||
name: 'Saison',
|
||||
type: 'select',
|
||||
options: ['Frühling', 'Sommer', 'Herbst', 'Winter', 'Ganzjährig'],
|
||||
order: 4,
|
||||
},
|
||||
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
name: 'Werkzeug',
|
||||
description: 'Handwerkzeug, Elektrowerkzeug',
|
||||
icon: '🔧',
|
||||
category: 'home',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
|
||||
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
|
||||
{
|
||||
id: 'type',
|
||||
name: 'Typ',
|
||||
type: 'select',
|
||||
options: ['Handwerkzeug', 'Elektrowerkzeug', 'Messwerkzeug', 'Sonstiges'],
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Zustand',
|
||||
type: 'select',
|
||||
options: ['Neu', 'Gut', 'Gebraucht', 'Defekt'],
|
||||
order: 3,
|
||||
},
|
||||
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kitchen',
|
||||
name: 'Küche',
|
||||
description: 'Küchengeräte, Geschirr, Besteck',
|
||||
icon: '🍳',
|
||||
category: 'home',
|
||||
schema: {
|
||||
fields: [
|
||||
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
|
||||
{ id: 'material', name: 'Material', type: 'text', order: 1 },
|
||||
{
|
||||
id: 'category',
|
||||
name: 'Kategorie',
|
||||
type: 'select',
|
||||
options: ['Gerät', 'Geschirr', 'Besteck', 'Topf/Pfanne', 'Sonstiges'],
|
||||
order: 2,
|
||||
},
|
||||
{ id: 'dishwasher_safe', name: 'Spülmaschinenfest', type: 'checkbox', order: 3 },
|
||||
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
name: 'Medien',
|
||||
description: 'Filme, Musik, Spiele',
|
||||
icon: '🎬',
|
||||
category: 'media',
|
||||
schema: {
|
||||
fields: [
|
||||
{
|
||||
id: 'format',
|
||||
name: 'Format',
|
||||
type: 'select',
|
||||
options: ['DVD', 'Blu-ray', 'CD', 'Vinyl', 'Digital', 'Kassette'],
|
||||
order: 0,
|
||||
},
|
||||
{ id: 'artist', name: 'Künstler/Regisseur', type: 'text', order: 1 },
|
||||
{ id: 'genre', name: 'Genre', type: 'text', order: 2 },
|
||||
{ id: 'year', name: 'Erscheinungsjahr', type: 'number', order: 3 },
|
||||
{
|
||||
id: 'rating',
|
||||
name: 'Bewertung',
|
||||
type: 'select',
|
||||
options: ['1', '2', '3', '4', '5'],
|
||||
order: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Benutzerdefiniert',
|
||||
description: 'Leere Sammlung, eigene Felder definieren',
|
||||
icon: '✨',
|
||||
category: 'other',
|
||||
schema: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
2
apps/inventar/packages/shared/src/index.ts
Normal file
2
apps/inventar/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types/index.js';
|
||||
export * from './constants/index.js';
|
||||
171
apps/inventar/packages/shared/src/types/index.ts
Normal file
171
apps/inventar/packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Field types for configurable schemas
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'date'
|
||||
| 'select'
|
||||
| 'tags'
|
||||
| 'checkbox'
|
||||
| 'url'
|
||||
| 'currency';
|
||||
|
||||
// Custom field definition (stored in collection schema)
|
||||
export interface FieldDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: string[]; // for select fields
|
||||
currencyCode?: string; // for currency fields
|
||||
placeholder?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// Collection schema
|
||||
export interface CollectionSchema {
|
||||
fields: FieldDefinition[];
|
||||
}
|
||||
|
||||
// Item status
|
||||
export type ItemStatus = 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed';
|
||||
|
||||
// Purchase data
|
||||
export interface PurchaseData {
|
||||
price?: number;
|
||||
currency?: string;
|
||||
date?: string;
|
||||
retailer?: string;
|
||||
warrantyExpiry?: string;
|
||||
receiptUrl?: string;
|
||||
}
|
||||
|
||||
// Item note
|
||||
export interface ItemNote {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Photo
|
||||
export interface ItemPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
caption?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// Document attachment
|
||||
export interface ItemDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// Collection
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
schema: CollectionSchema;
|
||||
templateId?: string;
|
||||
order: number;
|
||||
itemCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Location (hierarchical)
|
||||
export interface Location {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
order: number;
|
||||
children?: Location[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Category
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
order: number;
|
||||
children?: Category[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Item
|
||||
export interface Item {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
locationId?: string;
|
||||
categoryId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: ItemStatus;
|
||||
quantity: number;
|
||||
fieldValues: Record<string, unknown>;
|
||||
purchaseData?: PurchaseData;
|
||||
photos: ItemPhoto[];
|
||||
notes: ItemNote[];
|
||||
documents: ItemDocument[];
|
||||
tags: string[];
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Template definition
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
schema: CollectionSchema;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// Saved filter
|
||||
export interface SavedFilter {
|
||||
id: string;
|
||||
name: string;
|
||||
criteria: FilterCriteria;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FilterCriteria {
|
||||
search?: string;
|
||||
status?: ItemStatus[];
|
||||
locationId?: string;
|
||||
categoryId?: string;
|
||||
tagIds?: string[];
|
||||
collectionId?: string;
|
||||
}
|
||||
|
||||
// View mode
|
||||
export type ViewMode = 'list' | 'grid' | 'table';
|
||||
|
||||
// Sort options
|
||||
export type SortField = 'name' | 'createdAt' | 'updatedAt' | 'status' | 'quantity';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortOption {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
}
|
||||
16
apps/inventar/packages/shared/tsconfig.json
Normal file
16
apps/inventar/packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue