mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01:09 +02:00
refactor(go-services): integrate shared-go into crawler + gateway, fix Dockerfiles
- mana-crawler: config → envutil, handler → httputil.WriteJSON - mana-api-gateway: config → envutil, handlers → httputil.WriteJSON - Fix Dockerfile COPY paths (remove stale -go suffix in all 4 services) - All services now use packages/shared-go via replace directive Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba6dbf16c4
commit
bf4d9cb9aa
39 changed files with 1313 additions and 1379 deletions
288
apps/inventar/apps/web/src/lib/data/queries.ts
Normal file
288
apps/inventar/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for Inventar
|
||||
*
|
||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
||||
* (local writes, sync updates, other tabs). Components call these hooks
|
||||
* at init time; no manual fetch/refresh needed.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
collectionCollection,
|
||||
itemCollection,
|
||||
locationCollection,
|
||||
categoryCollection,
|
||||
type LocalCollection,
|
||||
type LocalItem,
|
||||
type LocalLocation,
|
||||
type LocalCategory,
|
||||
} from './local-store';
|
||||
import type {
|
||||
Collection,
|
||||
Item,
|
||||
Location,
|
||||
Category,
|
||||
ItemStatus,
|
||||
SortOption,
|
||||
FilterCriteria,
|
||||
} from '@inventar/shared';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
/** Convert a LocalCollection (IndexedDB) to the shared Collection type. */
|
||||
export function toCollection(local: LocalCollection): Collection {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description ?? undefined,
|
||||
icon: local.icon ?? undefined,
|
||||
color: local.color ?? undefined,
|
||||
schema: local.schema,
|
||||
templateId: local.templateId ?? undefined,
|
||||
order: local.order,
|
||||
itemCount: local.itemCount,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a LocalItem (IndexedDB) to the shared Item type. */
|
||||
export function toItem(local: LocalItem): Item {
|
||||
return {
|
||||
id: local.id,
|
||||
collectionId: local.collectionId,
|
||||
locationId: local.locationId ?? undefined,
|
||||
categoryId: local.categoryId ?? undefined,
|
||||
name: local.name,
|
||||
description: local.description ?? undefined,
|
||||
status: local.status,
|
||||
quantity: local.quantity,
|
||||
fieldValues: local.fieldValues,
|
||||
purchaseData: local.purchaseData ?? undefined,
|
||||
photos: local.photos,
|
||||
notes: local.notes,
|
||||
documents: [],
|
||||
tags: local.tags,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a LocalLocation (IndexedDB) to the shared Location type. */
|
||||
export function toLocation(local: LocalLocation): Location {
|
||||
return {
|
||||
id: local.id,
|
||||
parentId: local.parentId ?? undefined,
|
||||
name: local.name,
|
||||
description: local.description ?? undefined,
|
||||
icon: local.icon ?? undefined,
|
||||
path: local.path,
|
||||
depth: local.depth,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a LocalCategory (IndexedDB) to the shared Category type. */
|
||||
export function toCategory(local: LocalCategory): Category {
|
||||
return {
|
||||
id: local.id,
|
||||
parentId: local.parentId ?? undefined,
|
||||
name: local.name,
|
||||
icon: local.icon ?? undefined,
|
||||
color: local.color ?? undefined,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Query Hooks (call during component init) ─────────
|
||||
|
||||
/** All collections. Auto-updates on any change. */
|
||||
export function useAllCollections() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await collectionCollection.getAll();
|
||||
return locals.map(toCollection);
|
||||
}, [] as Collection[]);
|
||||
}
|
||||
|
||||
/** All items. Auto-updates on any change. */
|
||||
export function useAllItems() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await itemCollection.getAll();
|
||||
return locals.map(toItem);
|
||||
}, [] as Item[]);
|
||||
}
|
||||
|
||||
/** All locations. Auto-updates on any change. */
|
||||
export function useAllLocations() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await locationCollection.getAll();
|
||||
return locals.map(toLocation);
|
||||
}, [] as Location[]);
|
||||
}
|
||||
|
||||
/** All categories. Auto-updates on any change. */
|
||||
export function useAllCategories() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await categoryCollection.getAll();
|
||||
return locals.map(toCategory);
|
||||
}, [] as Category[]);
|
||||
}
|
||||
|
||||
// ─── Pure Collection Helpers ──────────────────────────────
|
||||
|
||||
/** Get a collection by ID. */
|
||||
export function getCollectionById(collections: Collection[], id: string): Collection | undefined {
|
||||
return collections.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
/** Get collections sorted by order. */
|
||||
export function getSortedCollections(collections: Collection[]): Collection[] {
|
||||
return [...collections].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// ─── Pure Item Helpers ────────────────────────────────────
|
||||
|
||||
/** Get an item by ID. */
|
||||
export function getItemById(items: Item[], id: string): Item | undefined {
|
||||
return items.find((i) => i.id === id);
|
||||
}
|
||||
|
||||
/** Get items for a specific collection. */
|
||||
export function getItemsByCollection(items: Item[], collectionId: string): Item[] {
|
||||
return items.filter((i) => i.collectionId === collectionId);
|
||||
}
|
||||
|
||||
/** Count items for a specific collection. */
|
||||
export function getItemCountByCollection(items: Item[], collectionId: string): number {
|
||||
return items.filter((i) => i.collectionId === collectionId).length;
|
||||
}
|
||||
|
||||
/** Get total item count. */
|
||||
export function getTotalItemCount(items: Item[]): number {
|
||||
return items.length;
|
||||
}
|
||||
|
||||
/** Filter items by criteria. */
|
||||
export function getFilteredItems(items: Item[], filters: FilterCriteria): 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;
|
||||
}
|
||||
|
||||
/** Sort items by a sort option. */
|
||||
export function getSortedItems(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;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pure Location Helpers ────────────────────────────────
|
||||
|
||||
/** Get a location by ID. */
|
||||
export function getLocationById(locations: Location[], id: string): Location | undefined {
|
||||
return locations.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
/** Get root locations (no parent). */
|
||||
export function getRootLocations(locations: Location[]): Location[] {
|
||||
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Get children of a location. */
|
||||
export function getLocationChildren(locations: Location[], parentId: string): Location[] {
|
||||
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Build a tree structure from flat locations. */
|
||||
export function getLocationTree(locations: Location[]): 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);
|
||||
}
|
||||
|
||||
/** Get full path for a location. */
|
||||
export function getLocationFullPath(locations: Location[], id: string): string {
|
||||
const location = locations.find((l) => l.id === id);
|
||||
if (!location) return '';
|
||||
return location.path ? `${location.path}/${location.name}` : location.name;
|
||||
}
|
||||
|
||||
// ─── Pure Category Helpers ────────────────────────────────
|
||||
|
||||
/** Get a category by ID. */
|
||||
export function getCategoryById(categories: Category[], id: string): Category | undefined {
|
||||
return categories.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
/** Get root categories (no parent). */
|
||||
export function getRootCategories(categories: Category[]): Category[] {
|
||||
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Get children of a category. */
|
||||
export function getCategoryChildren(categories: Category[], parentId: string): Category[] {
|
||||
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Build a tree structure from flat categories. */
|
||||
export function getCategoryTree(categories: Category[]): 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);
|
||||
}
|
||||
|
|
@ -1,100 +1,44 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Category } from '@inventar/shared';
|
||||
/**
|
||||
* Categories Store — Mutations Only
|
||||
*
|
||||
* Reads come from useLiveQuery (see $lib/data/queries.ts).
|
||||
* This store only handles writes to IndexedDB via local-store.
|
||||
*/
|
||||
|
||||
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);
|
||||
import { categoryCollection, type LocalCategory } from '$lib/data/local-store';
|
||||
import { toCategory } from '$lib/data/queries';
|
||||
|
||||
export const categoriesStore = {
|
||||
get categories() {
|
||||
return categories;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
async create(data: { name: string; icon?: string; color?: string; parentId?: string }) {
|
||||
const all = await categoryCollection.getAll();
|
||||
const siblings = all.filter((c) => c.parentId === data.parentId);
|
||||
|
||||
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,
|
||||
const newLocal: LocalCategory = {
|
||||
id: crypto.randomUUID(),
|
||||
parentId: data.parentId ?? null,
|
||||
name: data.name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
icon: data.icon ?? null,
|
||||
color: data.color ?? null,
|
||||
order: siblings.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
categories = [...categories, category];
|
||||
saveToStorage(categories);
|
||||
return category;
|
||||
const inserted = await categoryCollection.insert(newLocal);
|
||||
return toCategory(inserted);
|
||||
},
|
||||
|
||||
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);
|
||||
async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) {
|
||||
await categoryCollection.update(id, data);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
async delete(id: string) {
|
||||
const all = await categoryCollection.getAll();
|
||||
const idsToDelete = new Set<string>();
|
||||
const collectIds = (parentId: string) => {
|
||||
idsToDelete.add(parentId);
|
||||
categories.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
|
||||
all.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
|
||||
};
|
||||
collectIds(id);
|
||||
categories = categories.filter((c) => !idsToDelete.has(c.id));
|
||||
saveToStorage(categories);
|
||||
for (const deleteId of idsToDelete) {
|
||||
await categoryCollection.delete(deleteId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,102 +1,57 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Collection, CollectionSchema } from '@inventar/shared';
|
||||
/**
|
||||
* Collections Store — Mutations Only
|
||||
*
|
||||
* Reads come from useLiveQuery (see $lib/data/queries.ts).
|
||||
* This store only handles writes to IndexedDB via local-store.
|
||||
*/
|
||||
|
||||
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);
|
||||
import type { CollectionSchema } from '@inventar/shared';
|
||||
import { collectionCollection, type LocalCollection } from '$lib/data/local-store';
|
||||
import { toCollection } from '$lib/data/queries';
|
||||
|
||||
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: {
|
||||
async 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(),
|
||||
}) {
|
||||
const all = await collectionCollection.getAll();
|
||||
const newLocal: LocalCollection = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
description: data.description ?? null,
|
||||
icon: data.icon ?? null,
|
||||
color: data.color ?? null,
|
||||
schema: data.schema,
|
||||
templateId: data.templateId,
|
||||
order: collections.length,
|
||||
templateId: data.templateId ?? null,
|
||||
order: all.length,
|
||||
itemCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
collections = [...collections, collection];
|
||||
saveToStorage(collections);
|
||||
return collection;
|
||||
const inserted = await collectionCollection.insert(newLocal);
|
||||
return toCollection(inserted);
|
||||
},
|
||||
|
||||
update(
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Pick<Collection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
|
||||
data: Partial<Pick<LocalCollection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
|
||||
) {
|
||||
collections = collections.map((c) =>
|
||||
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
|
||||
);
|
||||
saveToStorage(collections);
|
||||
await collectionCollection.update(id, data);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
collections = collections.filter((c) => c.id !== id);
|
||||
saveToStorage(collections);
|
||||
async delete(id: string) {
|
||||
await collectionCollection.delete(id);
|
||||
},
|
||||
|
||||
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);
|
||||
async reorder(orderedIds: string[]) {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await collectionCollection.update(orderedIds[i], { order: i });
|
||||
}
|
||||
},
|
||||
|
||||
updateItemCount(collectionId: string, count: number) {
|
||||
collections = collections.map((c) => (c.id === collectionId ? { ...c, itemCount: count } : c));
|
||||
saveToStorage(collections);
|
||||
async updateItemCount(collectionId: string, count: number) {
|
||||
await collectionCollection.update(collectionId, { itemCount: count });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,122 +1,16 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type {
|
||||
Item,
|
||||
ItemStatus,
|
||||
ItemNote,
|
||||
ItemPhoto,
|
||||
PurchaseData,
|
||||
SortOption,
|
||||
} from '@inventar/shared';
|
||||
/**
|
||||
* Items Store — Mutations Only
|
||||
*
|
||||
* Reads come from useLiveQuery (see $lib/data/queries.ts).
|
||||
* This store only handles writes to IndexedDB via local-store.
|
||||
*/
|
||||
|
||||
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);
|
||||
import type { ItemStatus, PurchaseData, ItemPhoto, ItemNote } from '@inventar/shared';
|
||||
import { itemCollection, type LocalItem } from '$lib/data/local-store';
|
||||
import { toItem } from '$lib/data/queries';
|
||||
|
||||
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: {
|
||||
async create(data: {
|
||||
collectionId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
|
@ -127,37 +21,35 @@ export const itemsStore = {
|
|||
fieldValues?: Record<string, unknown>;
|
||||
purchaseData?: PurchaseData;
|
||||
tags?: string[];
|
||||
}): Item {
|
||||
const now = new Date().toISOString();
|
||||
const item: Item = {
|
||||
id: generateId(),
|
||||
}) {
|
||||
const existing = await itemCollection.getAll();
|
||||
const collectionItems = existing.filter((i) => i.collectionId === data.collectionId);
|
||||
|
||||
const newLocal: LocalItem = {
|
||||
id: crypto.randomUUID(),
|
||||
collectionId: data.collectionId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
description: data.description ?? null,
|
||||
status: data.status || 'owned',
|
||||
quantity: data.quantity || 1,
|
||||
locationId: data.locationId,
|
||||
categoryId: data.categoryId,
|
||||
locationId: data.locationId ?? null,
|
||||
categoryId: data.categoryId ?? null,
|
||||
fieldValues: data.fieldValues || {},
|
||||
purchaseData: data.purchaseData,
|
||||
purchaseData: data.purchaseData ?? null,
|
||||
photos: [],
|
||||
notes: [],
|
||||
documents: [],
|
||||
tags: data.tags || [],
|
||||
order: items.filter((i) => i.collectionId === data.collectionId).length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: collectionItems.length,
|
||||
};
|
||||
items = [...items, item];
|
||||
saveToStorage(items);
|
||||
return item;
|
||||
const inserted = await itemCollection.insert(newLocal);
|
||||
return toItem(inserted);
|
||||
},
|
||||
|
||||
update(
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Item,
|
||||
LocalItem,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'status'
|
||||
|
|
@ -170,91 +62,62 @@ export const itemsStore = {
|
|||
>
|
||||
>
|
||||
) {
|
||||
items = items.map((i) =>
|
||||
i.id === id ? { ...i, ...data, updatedAt: new Date().toISOString() } : i
|
||||
);
|
||||
saveToStorage(items);
|
||||
await itemCollection.update(id, data);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
items = items.filter((i) => i.id !== id);
|
||||
saveToStorage(items);
|
||||
async delete(id: string) {
|
||||
await itemCollection.delete(id);
|
||||
},
|
||||
|
||||
deleteByCollection(collectionId: string) {
|
||||
items = items.filter((i) => i.collectionId !== collectionId);
|
||||
saveToStorage(items);
|
||||
async deleteByCollection(collectionId: string) {
|
||||
const all = await itemCollection.getAll();
|
||||
const toDelete = all.filter((i) => i.collectionId === collectionId);
|
||||
for (const item of toDelete) {
|
||||
await itemCollection.delete(item.id);
|
||||
}
|
||||
},
|
||||
|
||||
addNote(itemId: string, content: string) {
|
||||
async addNote(itemId: string, content: string) {
|
||||
const item = await itemCollection.get(itemId);
|
||||
if (!item) return;
|
||||
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);
|
||||
const note: ItemNote = { id: crypto.randomUUID(), content, createdAt: now, updatedAt: now };
|
||||
await itemCollection.update(itemId, {
|
||||
notes: [...item.notes, note],
|
||||
});
|
||||
},
|
||||
|
||||
updateNote(itemId: string, noteId: string, content: string) {
|
||||
async updateNote(itemId: string, noteId: string, content: string) {
|
||||
const item = await itemCollection.get(itemId);
|
||||
if (!item) return;
|
||||
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);
|
||||
await itemCollection.update(itemId, {
|
||||
notes: item.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)),
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
async deleteNote(itemId: string, noteId: string) {
|
||||
const item = await itemCollection.get(itemId);
|
||||
if (!item) return;
|
||||
await itemCollection.update(itemId, {
|
||||
notes: item.notes.filter((n) => n.id !== noteId),
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
async addPhoto(itemId: string, photo: Omit<ItemPhoto, 'id' | 'order'>) {
|
||||
const item = await itemCollection.get(itemId);
|
||||
if (!item) return;
|
||||
const newPhoto: ItemPhoto = { ...photo, id: crypto.randomUUID(), order: item.photos.length };
|
||||
await itemCollection.update(itemId, {
|
||||
photos: [...item.photos, newPhoto],
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
async deletePhoto(itemId: string, photoId: string) {
|
||||
const item = await itemCollection.get(itemId);
|
||||
if (!item) return;
|
||||
await itemCollection.update(itemId, {
|
||||
photos: item.photos.filter((p) => p.id !== photoId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,124 +1,61 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Location } from '@inventar/shared';
|
||||
/**
|
||||
* Locations Store — Mutations Only
|
||||
*
|
||||
* Reads come from useLiveQuery (see $lib/data/queries.ts).
|
||||
* This store only handles writes to IndexedDB via local-store.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'inventar_locations';
|
||||
import { locationCollection, type LocalLocation } from '$lib/data/local-store';
|
||||
import { toLocation } from '$lib/data/queries';
|
||||
|
||||
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 {
|
||||
function buildPath(locations: LocalLocation[], 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 {
|
||||
function getDepth(locations: LocalLocation[], 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;
|
||||
},
|
||||
async create(data: { name: string; description?: string; icon?: string; parentId?: string }) {
|
||||
const all = await locationCollection.getAll();
|
||||
const path = buildPath(all, data.parentId);
|
||||
const depth = getDepth(all, data.parentId);
|
||||
const siblings = all.filter((l) => l.parentId === data.parentId);
|
||||
|
||||
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,
|
||||
const newLocal: LocalLocation = {
|
||||
id: crypto.randomUUID(),
|
||||
parentId: data.parentId ?? null,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
description: data.description ?? null,
|
||||
icon: data.icon ?? null,
|
||||
path,
|
||||
depth,
|
||||
order: siblings.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
locations = [...locations, location];
|
||||
saveToStorage(locations);
|
||||
return location;
|
||||
const inserted = await locationCollection.insert(newLocal);
|
||||
return toLocation(inserted);
|
||||
},
|
||||
|
||||
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);
|
||||
async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) {
|
||||
await locationCollection.update(id, data);
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
// Delete location and all children
|
||||
async delete(id: string) {
|
||||
const all = await locationCollection.getAll();
|
||||
const idsToDelete = new Set<string>();
|
||||
const collectIds = (parentId: string) => {
|
||||
idsToDelete.add(parentId);
|
||||
locations.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
|
||||
all.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
|
||||
};
|
||||
collectIds(id);
|
||||
locations = locations.filter((l) => !idsToDelete.has(l.id));
|
||||
saveToStorage(locations);
|
||||
for (const deleteId of idsToDelete) {
|
||||
await locationCollection.delete(deleteId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { setContext } from 'svelte';
|
||||
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';
|
||||
|
|
@ -15,6 +12,12 @@
|
|||
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { inventarStore } from '$lib/data/local-store';
|
||||
import {
|
||||
useAllCollections,
|
||||
useAllItems,
|
||||
useAllLocations,
|
||||
useAllCategories,
|
||||
} from '$lib/data/queries';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -22,6 +25,18 @@
|
|||
let initialized = $state(false);
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
|
||||
const allCollections = useAllCollections();
|
||||
const allItems = useAllItems();
|
||||
const allLocations = useAllLocations();
|
||||
const allCategories = useAllCategories();
|
||||
|
||||
// Provide data to child components via Svelte context
|
||||
setContext('collections', allCollections);
|
||||
setContext('items', allItems);
|
||||
setContext('locations', allLocations);
|
||||
setContext('categories', allCategories);
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database
|
||||
await inventarStore.initialize();
|
||||
|
|
@ -31,11 +46,7 @@
|
|||
inventarStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Initialize legacy localStorage stores (will be migrated to IndexedDB later)
|
||||
collectionsStore.initialize();
|
||||
itemsStore.initialize();
|
||||
locationsStore.initialize();
|
||||
categoriesStore.initialize();
|
||||
// Initialize view preferences (still localStorage-based, not data)
|
||||
viewStore.initialize();
|
||||
initialized = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { itemsStore } from '$lib/stores/items.svelte';
|
||||
import type { Collection } from '@inventar/shared';
|
||||
import {
|
||||
getSortedCollections,
|
||||
getItemCountByCollection,
|
||||
getTotalItemCount,
|
||||
} from '$lib/data/queries';
|
||||
import type { Collection, Item } from '@inventar/shared';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
const itemsCtx: { readonly value: Item[] } = getContext('items');
|
||||
|
||||
function getItemCount(collectionId: string): number {
|
||||
return itemsStore.getCountByCollection(collectionId);
|
||||
return getItemCountByCollection(itemsCtx.value, collectionId);
|
||||
}
|
||||
|
||||
function handleCollectionClick(collection: Collection) {
|
||||
|
|
@ -22,8 +31,9 @@
|
|||
}
|
||||
|
||||
// Stats
|
||||
let totalItems = $derived(itemsStore.getTotalCount());
|
||||
let totalCollections = $derived(collectionsStore.collections.length);
|
||||
let totalItems = $derived(getTotalItemCount(itemsCtx.value));
|
||||
let totalCollections = $derived(collectionsCtx.value.length);
|
||||
let sortedCollections = $derived(getSortedCollections(collectionsCtx.value));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -51,7 +61,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Collections grid -->
|
||||
{#if collectionsStore.collections.length === 0}
|
||||
{#if sortedCollections.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
|
|
@ -71,7 +81,7 @@
|
|||
</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)}
|
||||
{#each sortedCollections as collection (collection.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { categoriesStore } from '$lib/stores/categories.svelte';
|
||||
import type { Category } from '@inventar/shared';
|
||||
|
||||
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let name = $state('');
|
||||
|
|
@ -25,16 +28,16 @@
|
|||
showForm = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
async function save() {
|
||||
if (!name.trim()) return;
|
||||
if (editingId) {
|
||||
categoriesStore.update(editingId, {
|
||||
await categoriesStore.update(editingId, {
|
||||
name: name.trim(),
|
||||
icon: icon || undefined,
|
||||
color: color || undefined,
|
||||
});
|
||||
} else {
|
||||
categoriesStore.create({
|
||||
await categoriesStore.create({
|
||||
name: name.trim(),
|
||||
icon: icon || undefined,
|
||||
color: color || undefined,
|
||||
|
|
@ -43,12 +46,14 @@
|
|||
showForm = false;
|
||||
}
|
||||
|
||||
function deleteCategory(id: string) {
|
||||
async function deleteCategory(id: string) {
|
||||
if (confirm('Kategorie löschen?')) {
|
||||
categoriesStore.delete(id);
|
||||
await categoriesStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
let sortedCategories = $derived([...categoriesCtx.value].sort((a, b) => a.order - b.order));
|
||||
|
||||
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>
|
||||
|
|
@ -108,7 +113,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if categoriesStore.categories.length === 0}
|
||||
{#if sortedCategories.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
|
||||
>
|
||||
|
|
@ -117,7 +122,7 @@
|
|||
</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)}
|
||||
{#each sortedCategories 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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,23 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import { getContext } from '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 { getCollectionById, getItemsByCollection, getSortedItems } from '$lib/data/queries';
|
||||
import type { Collection, 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';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
const itemsCtx: { readonly value: Item[] } = getContext('items');
|
||||
|
||||
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));
|
||||
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
|
||||
let items = $derived(getItemsByCollection(itemsCtx.value, collectionId));
|
||||
let sortedItems = $derived(getSortedItems(items, viewStore.sort));
|
||||
|
||||
// Item creation
|
||||
let showNewItem = $state(false);
|
||||
|
|
@ -24,9 +26,9 @@
|
|||
let newItemFields = $state<Record<string, unknown>>({});
|
||||
let newItemStatus = $state<ItemStatus>('owned');
|
||||
|
||||
function createItem() {
|
||||
async function createItem() {
|
||||
if (!newItemName.trim() || !collection) return;
|
||||
itemsStore.create({
|
||||
await itemsStore.create({
|
||||
collectionId: collection.id,
|
||||
name: newItemName.trim(),
|
||||
status: newItemStatus,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { collectionsStore } from '$lib/stores/collections.svelte';
|
||||
import type { CollectionSchema } from '@inventar/shared';
|
||||
import { getCollectionById } from '$lib/data/queries';
|
||||
import type { Collection, CollectionSchema } from '@inventar/shared';
|
||||
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
|
||||
let collectionId = $derived($page.params.id);
|
||||
let collection = $derived(collectionsStore.getById(collectionId));
|
||||
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
|
@ -25,9 +29,9 @@
|
|||
}
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
async function handleSave() {
|
||||
if (!collection || !name.trim()) return;
|
||||
collectionsStore.update(collection.id, {
|
||||
await collectionsStore.update(collection.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
icon: icon || undefined,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@
|
|||
step = 'details';
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
collectionsStore.create({
|
||||
await collectionsStore.create({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
icon: icon || undefined,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
<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 { getContext } from 'svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { getFilteredItems, getSortedItems, getCollectionById } from '$lib/data/queries';
|
||||
import type { Collection, Item, ItemStatus } from '@inventar/shared';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||
import type { ItemStatus } from '@inventar/shared';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
const itemsCtx: { readonly value: Item[] } = getContext('items');
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredItems = $derived(
|
||||
itemsStore.getFiltered({
|
||||
getFilteredItems(itemsCtx.value, {
|
||||
search: searchQuery || undefined,
|
||||
...viewStore.activeFilters,
|
||||
})
|
||||
);
|
||||
let sortedItems = $derived(itemsStore.getSorted(filteredItems, viewStore.sort));
|
||||
let sortedItems = $derived(getSortedItems(filteredItems, viewStore.sort));
|
||||
|
||||
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
|
||||
|
||||
|
|
@ -33,7 +36,7 @@
|
|||
}
|
||||
|
||||
function getCollectionName(collectionId: string): string {
|
||||
return collectionsStore.getById(collectionId)?.name || '';
|
||||
return getCollectionById(collectionsCtx.value, collectionId)?.name || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,30 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
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 {
|
||||
getItemById,
|
||||
getCollectionById,
|
||||
getLocationById,
|
||||
getLocationFullPath,
|
||||
getCategoryById,
|
||||
} from '$lib/data/queries';
|
||||
import type { Collection, Item, Location, Category, 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';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
const itemsCtx: { readonly value: Item[] } = getContext('items');
|
||||
const locationsCtx: { readonly value: Location[] } = getContext('locations');
|
||||
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
|
||||
|
||||
let itemId = $derived($page.params.id);
|
||||
let item = $derived(itemsStore.getById(itemId));
|
||||
let collection = $derived(item ? collectionsStore.getById(item.collectionId) : undefined);
|
||||
let item = $derived(getItemById(itemsCtx.value, itemId));
|
||||
let collection = $derived(
|
||||
item ? getCollectionById(collectionsCtx.value, item.collectionId) : undefined
|
||||
);
|
||||
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
|
|
@ -41,9 +53,9 @@
|
|||
editing = true;
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
async function saveEdit() {
|
||||
if (!item || !editName.trim()) return;
|
||||
itemsStore.update(item.id, {
|
||||
await itemsStore.update(item.id, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
status: editStatus,
|
||||
|
|
@ -55,15 +67,15 @@
|
|||
editing = false;
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
async function addNote() {
|
||||
if (!item || !newNote.trim()) return;
|
||||
itemsStore.addNote(item.id, newNote.trim());
|
||||
await itemsStore.addNote(item.id, newNote.trim());
|
||||
newNote = '';
|
||||
}
|
||||
|
||||
function deleteItem() {
|
||||
async function deleteItem() {
|
||||
if (!item || !confirm('Item endgültig löschen?')) return;
|
||||
itemsStore.delete(item.id);
|
||||
await itemsStore.delete(item.id);
|
||||
goto(collection ? `/collections/${collection.id}` : '/items');
|
||||
}
|
||||
|
||||
|
|
@ -165,27 +177,27 @@
|
|||
>
|
||||
<input type="number" bind:value={editQuantity} min="1" class={inputClass} />
|
||||
</div>
|
||||
{#if locationsStore.locations.length > 0}
|
||||
{#if locationsCtx.value.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}
|
||||
{#each locationsCtx.value as loc}
|
||||
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{#if categoriesStore.categories.length > 0}
|
||||
{#if categoriesCtx.value.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}
|
||||
{#each categoriesCtx.value as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -227,15 +239,15 @@
|
|||
>
|
||||
{/if}
|
||||
{#if item.locationId}
|
||||
{@const loc = locationsStore.getById(item.locationId)}
|
||||
{@const loc = getLocationById(locationsCtx.value, item.locationId)}
|
||||
{#if loc}
|
||||
<span class="flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
📍 {locationsStore.getFullPath(loc.id)}
|
||||
📍 {getLocationFullPath(locationsCtx.value, loc.id)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.categoryId}
|
||||
{@const cat = categoriesStore.getById(item.categoryId)}
|
||||
{@const cat = getCategoryById(categoriesCtx.value, item.categoryId)}
|
||||
{#if cat}
|
||||
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
|
||||
>{cat.icon || '🏷️'} {cat.name}</span
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { locationsStore } from '$lib/stores/locations.svelte';
|
||||
import { getLocationTree } from '$lib/data/queries';
|
||||
import type { Location } from '@inventar/shared';
|
||||
|
||||
const locationsCtx: { readonly value: Location[] } = getContext('locations');
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let parentId = $state<string | undefined>();
|
||||
|
|
@ -25,12 +29,12 @@
|
|||
showForm = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
async function save() {
|
||||
if (!name.trim()) return;
|
||||
if (editingId) {
|
||||
locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
|
||||
await locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
|
||||
} else {
|
||||
locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
|
||||
await locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
|
||||
}
|
||||
showForm = false;
|
||||
name = '';
|
||||
|
|
@ -38,13 +42,13 @@
|
|||
editingId = null;
|
||||
}
|
||||
|
||||
function deleteLocation(id: string) {
|
||||
async function deleteLocation(id: string) {
|
||||
if (confirm('Standort und alle Unterstandorte löschen?')) {
|
||||
locationsStore.delete(id);
|
||||
await locationsStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
let tree = $derived(locationsStore.getTree());
|
||||
let tree = $derived(getLocationTree(locationsCtx.value));
|
||||
|
||||
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))]';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
<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 { getContext } from 'svelte';
|
||||
import { getFilteredItems, getCollectionById } from '$lib/data/queries';
|
||||
import type { Collection, Item } from '@inventar/shared';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
|
||||
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
|
||||
const itemsCtx: { readonly value: Item[] } = getContext('items');
|
||||
|
||||
let query = $state('');
|
||||
let results = $derived(query.length >= 2 ? itemsStore.getFiltered({ search: query }) : []);
|
||||
let results = $derived(
|
||||
query.length >= 2 ? getFilteredItems(itemsCtx.value, { search: query }) : []
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -53,7 +59,7 @@
|
|||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{collectionsStore.getById(item.collectionId)?.name || ''}
|
||||
{getCollectionById(collectionsCtx.value, item.collectionId)?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useAllMeals, useAllGoals, getDailySummary } from '$lib/data/queries';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import { WarningCircle, ArrowsClockwise } from '@manacore/shared-icons';
|
||||
|
||||
onMount(() => {
|
||||
mealsStore.fetchDailySummary();
|
||||
});
|
||||
// Reactive live queries — auto-update when IndexedDB changes
|
||||
const allMeals = useAllMeals();
|
||||
const allGoals = useAllGoals();
|
||||
const currentGoal = $derived(allGoals.value[0] ?? null);
|
||||
const summary = $derived(getDailySummary(allMeals.value, undefined, currentGoal));
|
||||
|
||||
let progress = $derived(mealsStore.dailySummary?.progress);
|
||||
let progress = $derived(summary?.progress);
|
||||
let caloriePercent = $derived(progress?.calories?.percentage ?? 0);
|
||||
|
||||
function retry() {
|
||||
mealsStore.clearErrors();
|
||||
mealsStore.fetchDailySummary();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-background-card)] rounded-2xl p-4 border border-[var(--color-border)]">
|
||||
|
|
@ -26,88 +21,64 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{#if mealsStore.summaryError}
|
||||
<div
|
||||
class="bg-red-500/10 border border-red-500/20 rounded-xl p-3 flex items-center gap-2 text-red-400 text-sm"
|
||||
<!-- Calories Ring -->
|
||||
<div class="flex items-center gap-6">
|
||||
<ProgressRing
|
||||
percentage={caloriePercent}
|
||||
size={100}
|
||||
strokeWidth={8}
|
||||
color="var(--color-calories)"
|
||||
>
|
||||
<WarningCircle class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="flex-1">{mealsStore.summaryError}</span>
|
||||
<button onclick={retry} class="p-1 hover:bg-red-500/20 rounded transition-colors">
|
||||
<ArrowsClockwise class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if mealsStore.summaryLoading}
|
||||
<div class="flex items-center gap-6 animate-pulse">
|
||||
<div class="w-[100px] h-[100px] rounded-full bg-[var(--color-background-elevated)]"></div>
|
||||
<div class="flex-1 grid grid-cols-3 gap-2">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="text-center">
|
||||
<div class="h-5 bg-[var(--color-background-elevated)] rounded mb-1"></div>
|
||||
<div class="h-3 bg-[var(--color-background-elevated)] rounded w-12 mx-auto"></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
{progress?.calories?.current ?? 0}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-secondary)]">
|
||||
/ {progress?.calories?.target ?? 2000}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Calories Ring -->
|
||||
<div class="flex items-center gap-6">
|
||||
<ProgressRing
|
||||
percentage={caloriePercent}
|
||||
size={100}
|
||||
strokeWidth={8}
|
||||
color="var(--color-calories)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
{progress?.calories?.current ?? 0}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-secondary)]">
|
||||
/ {progress?.calories?.target ?? 2000}
|
||||
</div>
|
||||
</div>
|
||||
</ProgressRing>
|
||||
</ProgressRing>
|
||||
|
||||
<!-- Macros -->
|
||||
<div class="flex-1 grid grid-cols-3 gap-2">
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-protein)]">
|
||||
{progress?.protein?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-protein)] transition-all"
|
||||
style="width: {progress?.protein?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Macros -->
|
||||
<div class="flex-1 grid grid-cols-3 gap-2">
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-protein)]">
|
||||
{progress?.protein?.current ?? 0}g
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-carbs)]">
|
||||
{progress?.carbs?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-carbs)] transition-all"
|
||||
style="width: {progress?.carbs?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-protein)] transition-all"
|
||||
style="width: {progress?.protein?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-fat)]">
|
||||
{progress?.fat?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-fat)] transition-all"
|
||||
style="width: {progress?.fat?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-carbs)]">
|
||||
{progress?.carbs?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-carbs)] transition-all"
|
||||
style="width: {progress?.carbs?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-fat)]">
|
||||
{progress?.fat?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-fat)] transition-all"
|
||||
style="width: {progress?.fat?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,74 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useAllMeals, getTodaysMeals } from '$lib/data/queries';
|
||||
import { MEAL_TYPE_LABELS } from '@nutriphi/shared';
|
||||
import {
|
||||
Trash,
|
||||
Camera,
|
||||
PencilLine,
|
||||
WarningCircle,
|
||||
ArrowsClockwise,
|
||||
CircleNotch,
|
||||
} from '@manacore/shared-icons';
|
||||
import { Trash, Camera, PencilLine, CircleNotch } from '@manacore/shared-icons';
|
||||
|
||||
// Reactive live query — auto-updates when IndexedDB changes
|
||||
const allMeals = useAllMeals();
|
||||
const todaysMeals = $derived(getTodaysMeals(allMeals.value));
|
||||
|
||||
let deleting = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
mealsStore.fetchTodaysMeals();
|
||||
});
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
async function deleteMeal(id: string) {
|
||||
if (confirm('Mahlzeit wirklich löschen?')) {
|
||||
if (confirm('Mahlzeit wirklich loschen?')) {
|
||||
deleting = id;
|
||||
deleteError = null;
|
||||
try {
|
||||
await mealsStore.deleteMeal(id);
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
} catch (err) {
|
||||
deleteError = err instanceof Error ? err.message : 'Mahlzeit konnte nicht geloscht werden';
|
||||
} finally {
|
||||
deleting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
mealsStore.clearErrors();
|
||||
mealsStore.fetchTodaysMeals();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if mealsStore.error}
|
||||
<div
|
||||
class="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-center gap-3 text-red-400"
|
||||
>
|
||||
<WarningCircle class="w-5 h-5 flex-shrink-0" />
|
||||
<span class="flex-1 text-sm">{mealsStore.error}</span>
|
||||
<button onclick={retry} class="p-2 hover:bg-red-500/20 rounded-lg transition-colors">
|
||||
<ArrowsClockwise class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mealsStore.deleteError}
|
||||
{#if deleteError}
|
||||
<div
|
||||
class="bg-red-500/10 border border-red-500/20 rounded-xl p-3 flex items-center gap-2 text-red-400 text-sm"
|
||||
>
|
||||
<WarningCircle class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{mealsStore.deleteError}</span>
|
||||
<span>{deleteError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mealsStore.loading}
|
||||
<div class="text-center py-8 text-[var(--color-text-secondary)]">Laden...</div>
|
||||
{:else if !mealsStore.error && mealsStore.meals.length === 0}
|
||||
{#if todaysMeals.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-[var(--color-text-secondary)] mb-2">Noch keine Mahlzeiten heute</p>
|
||||
<p class="text-sm text-[var(--color-text-muted)]">
|
||||
Tippe auf + um deine erste Mahlzeit hinzuzufügen
|
||||
Tippe auf + um deine erste Mahlzeit hinzuzufugen
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each mealsStore.meals as meal (meal.id)}
|
||||
{#each todaysMeals as meal (meal.id)}
|
||||
<div
|
||||
class="bg-[var(--color-background-card)] rounded-xl p-4 border border-[var(--color-border)]"
|
||||
>
|
||||
|
|
|
|||
174
apps/nutriphi/apps/web/src/lib/data/queries.ts
Normal file
174
apps/nutriphi/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for NutriPhi
|
||||
*
|
||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
||||
* (local writes, sync updates, other tabs). Components call these hooks
|
||||
* at init time; no manual fetch/refresh needed.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
mealCollection,
|
||||
goalCollection,
|
||||
favoriteCollection,
|
||||
type LocalMeal,
|
||||
type LocalGoal,
|
||||
type LocalFavorite,
|
||||
} from './local-store';
|
||||
import type { Meal, MealNutrition, DailySummary, NutritionProgress } from '@nutriphi/shared';
|
||||
|
||||
// ─── Extended Types ────────────────────────────────────────
|
||||
|
||||
export interface MealWithNutrition extends Meal {
|
||||
nutrition: MealNutrition | null;
|
||||
}
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
date: new Date(local.date),
|
||||
mealType: local.mealType as any,
|
||||
inputType: local.inputType as any,
|
||||
description: local.description,
|
||||
portionSize: local.portionSize ?? undefined,
|
||||
confidence: local.confidence,
|
||||
createdAt: new Date(local.createdAt ?? Date.now()),
|
||||
nutrition: local.nutrition
|
||||
? {
|
||||
id: local.id,
|
||||
mealId: local.id,
|
||||
calories: local.nutrition.calories,
|
||||
protein: local.nutrition.protein,
|
||||
carbohydrates: local.nutrition.carbohydrates,
|
||||
fat: local.nutrition.fat,
|
||||
fiber: local.nutrition.fiber,
|
||||
sugar: local.nutrition.sugar,
|
||||
}
|
||||
: null,
|
||||
} as MealWithNutrition;
|
||||
}
|
||||
|
||||
// ─── Live Query Hooks (call during component init) ─────────
|
||||
|
||||
/** All meals, auto-updates on any change. */
|
||||
export function useAllMeals() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await mealCollection.getAll();
|
||||
return locals.map(toMealWithNutrition);
|
||||
}, [] as MealWithNutrition[]);
|
||||
}
|
||||
|
||||
/** All goals, auto-updates on any change. */
|
||||
export function useAllGoals() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
return await goalCollection.getAll();
|
||||
}, [] as LocalGoal[]);
|
||||
}
|
||||
|
||||
/** All favorites, auto-updates on any change. */
|
||||
export function useAllFavorites() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
return await favoriteCollection.getAll();
|
||||
}, [] as LocalFavorite[]);
|
||||
}
|
||||
|
||||
// ─── Pure Filter/Helper Functions (for $derived) ──────────
|
||||
|
||||
/** Filter meals for a specific date string (YYYY-MM-DD). */
|
||||
export function filterByDate(meals: MealWithNutrition[], dateStr: string): MealWithNutrition[] {
|
||||
return meals.filter((m) => {
|
||||
const mealDate =
|
||||
m.date instanceof Date ? m.date.toISOString().split('T')[0] : String(m.date).split('T')[0];
|
||||
return mealDate === dateStr;
|
||||
});
|
||||
}
|
||||
|
||||
/** Get today's date as YYYY-MM-DD string. */
|
||||
export function getTodayStr(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** Filter meals for today, sorted by creation time. */
|
||||
export function getTodaysMeals(meals: MealWithNutrition[]): MealWithNutrition[] {
|
||||
const today = getTodayStr();
|
||||
return filterByDate(meals, today).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/** Sum nutrition values across a set of meals. */
|
||||
export function sumNutrition(meals: MealWithNutrition[]): {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
} {
|
||||
return meals.reduce(
|
||||
(acc, m) => ({
|
||||
calories: acc.calories + (m.nutrition?.calories || 0),
|
||||
protein: acc.protein + (m.nutrition?.protein || 0),
|
||||
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
|
||||
fat: acc.fat + (m.nutrition?.fat || 0),
|
||||
fiber: acc.fiber + (m.nutrition?.fiber || 0),
|
||||
sugar: acc.sugar + (m.nutrition?.sugar || 0),
|
||||
}),
|
||||
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a DailySummary from meals for a given date. */
|
||||
export function getDailySummary(
|
||||
meals: MealWithNutrition[],
|
||||
date?: Date,
|
||||
goals?: LocalGoal | null
|
||||
): DailySummary {
|
||||
const dateStr = (date || new Date()).toISOString().split('T')[0];
|
||||
const dayMeals = filterByDate(meals, dateStr);
|
||||
const totalNutrition = sumNutrition(dayMeals);
|
||||
|
||||
const calorieTarget = goals?.dailyCalories ?? 2000;
|
||||
const proteinTarget = goals?.dailyProtein ?? 50;
|
||||
const carbsTarget = goals?.dailyCarbs ?? 250;
|
||||
const fatTarget = goals?.dailyFat ?? 65;
|
||||
|
||||
const progress: NutritionProgress = {
|
||||
calories: {
|
||||
current: Math.round(totalNutrition.calories),
|
||||
target: calorieTarget,
|
||||
percentage: Math.min(Math.round((totalNutrition.calories / calorieTarget) * 100), 100),
|
||||
},
|
||||
protein: {
|
||||
current: Math.round(totalNutrition.protein),
|
||||
target: proteinTarget,
|
||||
percentage: Math.min(Math.round((totalNutrition.protein / proteinTarget) * 100), 100),
|
||||
},
|
||||
carbs: {
|
||||
current: Math.round(totalNutrition.carbohydrates),
|
||||
target: carbsTarget,
|
||||
percentage: Math.min(Math.round((totalNutrition.carbohydrates / carbsTarget) * 100), 100),
|
||||
},
|
||||
fat: {
|
||||
current: Math.round(totalNutrition.fat),
|
||||
target: fatTarget,
|
||||
percentage: Math.min(Math.round((totalNutrition.fat / fatTarget) * 100), 100),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
date: new Date(dateStr),
|
||||
meals: dayMeals,
|
||||
totalNutrition,
|
||||
progress,
|
||||
} as DailySummary;
|
||||
}
|
||||
|
||||
/** Search meals by description. */
|
||||
export function searchMeals(meals: MealWithNutrition[], query: string): MealWithNutrition[] {
|
||||
const q = query.toLowerCase();
|
||||
return meals.filter((m) => m.description?.toLowerCase().includes(q));
|
||||
}
|
||||
|
|
@ -1,168 +1,56 @@
|
|||
/**
|
||||
* Meals Store — Local-First with @manacore/local-store
|
||||
* Meals Store — Write Actions Only
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Reads are handled by useLiveQuery hooks in queries.ts.
|
||||
* This store only exposes mutation actions that write to IndexedDB.
|
||||
*/
|
||||
|
||||
import { mealCollection, type LocalMeal } from '$lib/data/local-store';
|
||||
import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared';
|
||||
import { NutriPhiEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
interface MealWithNutrition extends Meal {
|
||||
nutrition: MealNutrition | null;
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
async function addMeal(mealData: {
|
||||
date: string;
|
||||
mealType: string;
|
||||
inputType: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
}) {
|
||||
const newMeal: LocalMeal = {
|
||||
id: crypto.randomUUID(),
|
||||
date: mealData.date,
|
||||
mealType: mealData.mealType as any,
|
||||
inputType: mealData.inputType as any,
|
||||
description: mealData.description,
|
||||
confidence: mealData.confidence,
|
||||
nutrition: {
|
||||
calories: mealData.calories,
|
||||
protein: mealData.protein,
|
||||
carbohydrates: mealData.carbohydrates,
|
||||
fat: mealData.fat,
|
||||
fiber: mealData.fiber || 0,
|
||||
sugar: mealData.sugar || 0,
|
||||
},
|
||||
};
|
||||
|
||||
const inserted = await mealCollection.insert(newMeal);
|
||||
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
date: new Date(local.date),
|
||||
mealType: local.mealType as any,
|
||||
inputType: local.inputType as any,
|
||||
description: local.description,
|
||||
portionSize: local.portionSize ?? undefined,
|
||||
confidence: local.confidence,
|
||||
createdAt: new Date(local.createdAt ?? Date.now()),
|
||||
nutrition: local.nutrition
|
||||
? {
|
||||
id: local.id,
|
||||
mealId: local.id,
|
||||
calories: local.nutrition.calories,
|
||||
protein: local.nutrition.protein,
|
||||
carbohydrates: local.nutrition.carbohydrates,
|
||||
fat: local.nutrition.fat,
|
||||
fiber: local.nutrition.fiber,
|
||||
sugar: local.nutrition.sugar,
|
||||
}
|
||||
: null,
|
||||
} as MealWithNutrition;
|
||||
async function deleteMeal(mealId: string) {
|
||||
await mealCollection.delete(mealId);
|
||||
NutriPhiEvents.mealDeleted();
|
||||
}
|
||||
|
||||
class MealsStore {
|
||||
meals = $state<MealWithNutrition[]>([]);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
dailySummary = $state<DailySummary | null>(null);
|
||||
summaryLoading = $state(false);
|
||||
summaryError = $state<string | null>(null);
|
||||
deleteError = $state<string | null>(null);
|
||||
|
||||
async fetchTodaysMeals() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const allMeals = await mealCollection.getAll();
|
||||
this.meals = allMeals
|
||||
.filter((m) => m.date === today)
|
||||
.map(toMealWithNutrition)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchDailySummary(date?: Date) {
|
||||
this.summaryLoading = true;
|
||||
this.summaryError = null;
|
||||
try {
|
||||
const dateStr = (date || new Date()).toISOString().split('T')[0];
|
||||
const allMeals = await mealCollection.getAll();
|
||||
const dayMeals = allMeals.filter((m) => m.date === dateStr);
|
||||
|
||||
const totalNutrition = dayMeals.reduce(
|
||||
(acc, m) => ({
|
||||
calories: acc.calories + (m.nutrition?.calories || 0),
|
||||
protein: acc.protein + (m.nutrition?.protein || 0),
|
||||
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
|
||||
fat: acc.fat + (m.nutrition?.fat || 0),
|
||||
fiber: acc.fiber + (m.nutrition?.fiber || 0),
|
||||
sugar: acc.sugar + (m.nutrition?.sugar || 0),
|
||||
}),
|
||||
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
|
||||
);
|
||||
|
||||
this.dailySummary = {
|
||||
date: new Date(dateStr),
|
||||
meals: dayMeals.map(toMealWithNutrition),
|
||||
totalNutrition,
|
||||
} as DailySummary;
|
||||
} catch (err) {
|
||||
this.summaryError =
|
||||
err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden';
|
||||
} finally {
|
||||
this.summaryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addMeal(mealData: {
|
||||
date: string;
|
||||
mealType: string;
|
||||
inputType: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
}) {
|
||||
this.error = null;
|
||||
try {
|
||||
const newMeal: LocalMeal = {
|
||||
id: crypto.randomUUID(),
|
||||
date: mealData.date,
|
||||
mealType: mealData.mealType as any,
|
||||
inputType: mealData.inputType as any,
|
||||
description: mealData.description,
|
||||
confidence: mealData.confidence,
|
||||
nutrition: {
|
||||
calories: mealData.calories,
|
||||
protein: mealData.protein,
|
||||
carbohydrates: mealData.carbohydrates,
|
||||
fat: mealData.fat,
|
||||
fiber: mealData.fiber || 0,
|
||||
sugar: mealData.sugar || 0,
|
||||
},
|
||||
};
|
||||
|
||||
const inserted = await mealCollection.insert(newMeal);
|
||||
const meal = toMealWithNutrition(inserted);
|
||||
this.meals = [...this.meals, meal];
|
||||
await this.fetchDailySummary();
|
||||
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
|
||||
return meal;
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Mahlzeit konnte nicht gespeichert werden';
|
||||
this.error = message;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMeal(mealId: string) {
|
||||
this.deleteError = null;
|
||||
try {
|
||||
await mealCollection.delete(mealId);
|
||||
this.meals = this.meals.filter((m) => m.id !== mealId);
|
||||
await this.fetchDailySummary();
|
||||
NutriPhiEvents.mealDeleted();
|
||||
} catch (err) {
|
||||
this.deleteError =
|
||||
err instanceof Error ? err.message : 'Mahlzeit konnte nicht gelöscht werden';
|
||||
throw new Error(this.deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.error = null;
|
||||
this.summaryError = null;
|
||||
this.deleteError = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const mealsStore = new MealsStore();
|
||||
export const mealsStore = {
|
||||
addMeal,
|
||||
deleteMeal,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { QuickInputBar } from '@manacore/shared-ui';
|
||||
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { useAllMeals, searchMeals } from '$lib/data/queries';
|
||||
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
|
|
@ -15,11 +15,12 @@
|
|||
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
// Reactive live query for search
|
||||
const allMeals = useAllMeals();
|
||||
|
||||
// QuickInputBar handlers - search recent meals
|
||||
async function handleSearch(query: string): Promise<QuickInputItem[]> {
|
||||
const q = query.toLowerCase();
|
||||
return mealsStore.meals
|
||||
.filter((m) => m.description?.toLowerCase().includes(q))
|
||||
return searchMeals(allMeals.value, query)
|
||||
.slice(0, 10)
|
||||
.map((meal) => ({
|
||||
id: meal.id,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import {
|
||||
useAllSkills,
|
||||
useAllActivities,
|
||||
useAllAchievements,
|
||||
computeUserStats,
|
||||
} from '$lib/data/queries';
|
||||
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
|
||||
import { Trophy, Lightning, Target, Fire, Medal } from '@manacore/shared-icons';
|
||||
|
||||
// Reactive live queries
|
||||
const allSkills = useAllSkills();
|
||||
const allActivities = useAllActivities();
|
||||
const allAchievementsRaw = useAllAchievements();
|
||||
|
||||
const userStats = $derived(computeUserStats(allSkills.value, allActivities.value));
|
||||
const achievementStats = $derived(
|
||||
getAchievementStats(buildAchievementStatus(allAchievementsRaw.value))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
|
|
@ -14,7 +29,7 @@
|
|||
<div>
|
||||
<p class="text-sm text-gray-400">Gesamt-XP</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalXp.toLocaleString()}
|
||||
{userStats.totalXp.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,7 +44,7 @@
|
|||
<div>
|
||||
<p class="text-sm text-gray-400">Skills</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalSkills}
|
||||
{userStats.totalSkills}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,9 +57,9 @@
|
|||
<Trophy class="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Höchstes Level</p>
|
||||
<p class="text-sm text-gray-400">Hochstes Level</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.highestLevel}
|
||||
{userStats.highestLevel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,7 +74,7 @@
|
|||
<div>
|
||||
<p class="text-sm text-gray-400">Streak</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.streakDays} Tage
|
||||
{userStats.streakDays} Tage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -77,8 +92,8 @@
|
|||
<div>
|
||||
<p class="text-sm text-gray-400">Achievements</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{achievementStore.stats().unlocked}<span class="text-sm font-normal text-gray-500"
|
||||
>/{achievementStore.stats().total}</span
|
||||
{achievementStats.unlocked}<span class="text-sm font-normal text-gray-500"
|
||||
>/{achievementStats.total}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
186
apps/skilltree/apps/web/src/lib/data/queries.ts
Normal file
186
apps/skilltree/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for SkilltTree
|
||||
*
|
||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
||||
* (local writes, sync updates, other tabs). Components call these hooks
|
||||
* at init time; no manual fetch/refresh needed.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
skillCollection,
|
||||
activityCollection,
|
||||
achievementCollection,
|
||||
type LocalSkill,
|
||||
type LocalActivity,
|
||||
type LocalAchievement,
|
||||
} from './local-store';
|
||||
import type { Skill, Activity, SkillBranch, UserStats } from '$lib/types';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toSkill(local: LocalSkill): Skill {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description,
|
||||
branch: local.branch,
|
||||
parentId: local.parentId ?? null,
|
||||
icon: local.icon,
|
||||
color: local.color ?? null,
|
||||
currentXp: local.currentXp,
|
||||
totalXp: local.totalXp,
|
||||
level: local.level,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toActivity(local: LocalActivity): Activity {
|
||||
return {
|
||||
id: local.id,
|
||||
skillId: local.skillId,
|
||||
xpEarned: local.xpEarned,
|
||||
description: local.description,
|
||||
duration: local.duration ?? null,
|
||||
timestamp: local.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Query Hooks (call during component init) ─────────
|
||||
|
||||
/** All skills, auto-updates on any change. */
|
||||
export function useAllSkills() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await skillCollection.getAll();
|
||||
return locals.map(toSkill);
|
||||
}, [] as Skill[]);
|
||||
}
|
||||
|
||||
/** All activities, auto-updates on any change. */
|
||||
export function useAllActivities() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await activityCollection.getAll();
|
||||
return locals.map(toActivity);
|
||||
}, [] as Activity[]);
|
||||
}
|
||||
|
||||
/** All achievements (raw local records), auto-updates on any change. */
|
||||
export function useAllAchievements() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
return await achievementCollection.getAll();
|
||||
}, [] as LocalAchievement[]);
|
||||
}
|
||||
|
||||
// ─── Pure Filter/Helper Functions (for $derived) ──────────
|
||||
|
||||
/** Group skills by branch. */
|
||||
export function groupByBranch(skills: Skill[]): Record<SkillBranch, Skill[]> {
|
||||
const grouped: Record<SkillBranch, Skill[]> = {
|
||||
intellect: [],
|
||||
body: [],
|
||||
creativity: [],
|
||||
social: [],
|
||||
practical: [],
|
||||
mindset: [],
|
||||
custom: [],
|
||||
};
|
||||
for (const skill of skills) {
|
||||
grouped[skill.branch].push(skill);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/** Get top N skills by total XP. */
|
||||
export function getTopSkills(skills: Skill[], n = 5): Skill[] {
|
||||
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n);
|
||||
}
|
||||
|
||||
/** Get recent N activities sorted by timestamp descending. */
|
||||
export function getRecentActivities(activities: Activity[], n = 10): Activity[] {
|
||||
return [...activities]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
/** Compute branch-level stats. */
|
||||
export function computeBranchStats(
|
||||
skills: Skill[]
|
||||
): Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> {
|
||||
const stats = {} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
|
||||
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
|
||||
const branchSkills = skills.filter((s) => s.branch === branch);
|
||||
stats[branch] = {
|
||||
count: branchSkills.length,
|
||||
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
avgLevel:
|
||||
branchSkills.length > 0
|
||||
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Calculate activity streak in days. */
|
||||
export function calculateStreak(activities: Activity[]): number {
|
||||
if (activities.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const sortedDates = activities
|
||||
.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of sortedDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
/** Compute aggregate user stats from skills and activities. */
|
||||
export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats {
|
||||
const sortedActivities = [...activities].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
totalSkills: skills.length,
|
||||
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
|
||||
streakDays: calculateStreak(activities),
|
||||
lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter skills by branch (or return all if 'all'). */
|
||||
export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] {
|
||||
if (branch === 'all') return skills;
|
||||
return skills.filter((s) => s.branch === branch);
|
||||
}
|
||||
|
||||
/** Find a skill by ID. */
|
||||
export function getSkillById(skills: Skill[], id: string): Skill | undefined {
|
||||
return skills.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Get all activities for a specific skill. */
|
||||
export function getSkillActivities(activities: Activity[], skillId: string): Activity[] {
|
||||
return activities.filter((a) => a.skillId === skillId);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Achievements Store — Local-First with @manacore/local-store
|
||||
* Achievements Store — Write Actions + Unlock Queue
|
||||
*
|
||||
* All achievement state stored in IndexedDB via Dexie.js.
|
||||
* Sync to server happens automatically when authenticated.
|
||||
* Reads are handled by useLiveQuery hooks in queries.ts.
|
||||
* This store handles achievement checking logic and the unlock celebration queue.
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
|
@ -16,25 +16,47 @@ import type {
|
|||
import { ACHIEVEMENT_DEFINITIONS } from '$lib/types';
|
||||
import { achievementCollection, type LocalAchievement } from '$lib/data/local-store';
|
||||
|
||||
// Reactive state
|
||||
let achievements = $state<AchievementWithStatus[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
// Queue of recently unlocked achievements to show celebrations
|
||||
let unlockQueue = $state<AchievementUnlockResult[]>([]);
|
||||
|
||||
// ─── Derived values ──────────────────────────────────────────
|
||||
// ─── Derived helpers (pure functions for consumers) ──────────
|
||||
|
||||
const unlockedAchievements = $derived(() => {
|
||||
/** Build achievement status list from stored records and definitions. */
|
||||
export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] {
|
||||
if (stored.length === 0) {
|
||||
return ACHIEVEMENT_DEFINITIONS.map((def) => ({
|
||||
...def,
|
||||
unlocked: false,
|
||||
unlockedAt: null,
|
||||
progress: 0,
|
||||
}));
|
||||
}
|
||||
return ACHIEVEMENT_DEFINITIONS.map((def) => {
|
||||
const found = stored.find((s) => s.key === def.id || s.id === def.id);
|
||||
return {
|
||||
...def,
|
||||
unlocked: found?.unlockedAt ? true : false,
|
||||
unlockedAt: found?.unlockedAt || null,
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getUnlockedAchievements(
|
||||
achievements: AchievementWithStatus[]
|
||||
): AchievementWithStatus[] {
|
||||
return achievements.filter((a) => a.unlocked);
|
||||
});
|
||||
}
|
||||
|
||||
const lockedAchievements = $derived(() => {
|
||||
export function getLockedAchievements(
|
||||
achievements: AchievementWithStatus[]
|
||||
): AchievementWithStatus[] {
|
||||
return achievements.filter((a) => !a.unlocked);
|
||||
});
|
||||
}
|
||||
|
||||
const achievementsByCategory = $derived(() => {
|
||||
export function getAchievementsByCategory(
|
||||
achievements: AchievementWithStatus[]
|
||||
): Record<AchievementCategory, AchievementWithStatus[]> {
|
||||
const grouped: Record<AchievementCategory, AchievementWithStatus[]> = {
|
||||
xp: [],
|
||||
skills: [],
|
||||
|
|
@ -48,81 +70,41 @@ const achievementsByCategory = $derived(() => {
|
|||
grouped[a.category].push(a);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
}
|
||||
|
||||
const stats = $derived(() => {
|
||||
export function getAchievementStats(achievements: AchievementWithStatus[]): {
|
||||
total: number;
|
||||
unlocked: number;
|
||||
} {
|
||||
return {
|
||||
total: achievements.length,
|
||||
unlocked: achievements.filter((a) => a.unlocked).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const completionPercentage = $derived(() => {
|
||||
export function getCompletionPercentage(achievements: AchievementWithStatus[]): number {
|
||||
if (achievements.length === 0) return 0;
|
||||
return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
async function initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const stored = await achievementCollection.getAll();
|
||||
if (stored.length === 0) {
|
||||
// First time: seed from definitions
|
||||
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({
|
||||
...def,
|
||||
unlocked: false,
|
||||
unlockedAt: null,
|
||||
progress: 0,
|
||||
}));
|
||||
// Save each to IndexedDB
|
||||
for (const a of achievements) {
|
||||
await achievementCollection.insert({
|
||||
id: a.id,
|
||||
key: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
unlockedAt: '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Merge stored data with definitions (in case new achievements were added)
|
||||
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => {
|
||||
const found = stored.find((s) => s.key === def.id || s.id === def.id);
|
||||
return {
|
||||
...def,
|
||||
unlocked: found?.unlockedAt ? true : false,
|
||||
unlockedAt: found?.unlockedAt || null,
|
||||
progress: 0,
|
||||
};
|
||||
async function seedIfEmpty() {
|
||||
const stored = await achievementCollection.getAll();
|
||||
if (stored.length === 0) {
|
||||
for (const def of ACHIEVEMENT_DEFINITIONS) {
|
||||
await achievementCollection.insert({
|
||||
id: def.id,
|
||||
key: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
unlockedAt: '',
|
||||
});
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize achievements store:', error);
|
||||
// Fallback to definitions
|
||||
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({
|
||||
...def,
|
||||
unlocked: false,
|
||||
unlockedAt: null,
|
||||
progress: 0,
|
||||
}));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reinitialize() {
|
||||
initialized = false;
|
||||
achievements = [];
|
||||
unlockQueue = [];
|
||||
await initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check achievements locally (offline mode).
|
||||
* Called after skill/activity changes.
|
||||
|
|
@ -135,6 +117,10 @@ async function checkLocal(context: {
|
|||
}): Promise<AchievementUnlockResult[]> {
|
||||
const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context;
|
||||
|
||||
// Get current achievements from DB
|
||||
const stored = await achievementCollection.getAll();
|
||||
const achievements = buildAchievementStatus(stored);
|
||||
|
||||
const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom'));
|
||||
|
||||
const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
|
||||
|
|
@ -161,8 +147,7 @@ async function checkLocal(context: {
|
|||
|
||||
const newlyUnlocked: AchievementUnlockResult[] = [];
|
||||
|
||||
for (let i = 0; i < achievements.length; i++) {
|
||||
const a = achievements[i];
|
||||
for (const a of achievements) {
|
||||
if (a.unlocked) continue;
|
||||
|
||||
const condition = a.condition;
|
||||
|
|
@ -205,23 +190,10 @@ async function checkLocal(context: {
|
|||
}
|
||||
|
||||
if (met) {
|
||||
const unlocked: AchievementWithStatus = {
|
||||
...a,
|
||||
unlocked: true,
|
||||
unlockedAt: new Date().toISOString(),
|
||||
progress: condition.threshold,
|
||||
};
|
||||
achievements = [...achievements.slice(0, i), unlocked, ...achievements.slice(i + 1)];
|
||||
await achievementCollection.update(a.id, {
|
||||
unlockedAt: unlocked.unlockedAt!,
|
||||
unlockedAt: new Date().toISOString(),
|
||||
});
|
||||
newlyUnlocked.push({ achievement: a, xpReward: a.xpReward });
|
||||
} else {
|
||||
// Update progress
|
||||
const updated = { ...a, progress: Math.min(current, condition.threshold) };
|
||||
if (updated.progress !== a.progress) {
|
||||
achievements = [...achievements.slice(0, i), updated, ...achievements.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,31 +204,6 @@ async function checkLocal(context: {
|
|||
return newlyUnlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle achievements returned from server sync.
|
||||
*/
|
||||
function handleApiUnlocks(results: AchievementUnlockResult[]) {
|
||||
if (results.length === 0) return;
|
||||
|
||||
for (const result of results) {
|
||||
const index = achievements.findIndex((a) => a.id === result.achievement.id);
|
||||
if (index !== -1) {
|
||||
achievements = [
|
||||
...achievements.slice(0, index),
|
||||
{
|
||||
...achievements[index],
|
||||
unlocked: true,
|
||||
unlockedAt: new Date().toISOString(),
|
||||
progress: achievements[index].condition.threshold,
|
||||
},
|
||||
...achievements.slice(index + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
unlockQueue = [...unlockQueue, ...results];
|
||||
}
|
||||
|
||||
function popUnlockQueue(): AchievementUnlockResult | null {
|
||||
if (unlockQueue.length === 0) return null;
|
||||
const [first, ...rest] = unlockQueue;
|
||||
|
|
@ -265,37 +212,11 @@ function popUnlockQueue(): AchievementUnlockResult | null {
|
|||
}
|
||||
|
||||
export const achievementStore = {
|
||||
get achievements() {
|
||||
return achievements;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
get unlockedAchievements() {
|
||||
return unlockedAchievements;
|
||||
},
|
||||
get lockedAchievements() {
|
||||
return lockedAchievements;
|
||||
},
|
||||
get achievementsByCategory() {
|
||||
return achievementsByCategory;
|
||||
},
|
||||
get stats() {
|
||||
return stats;
|
||||
},
|
||||
get completionPercentage() {
|
||||
return completionPercentage;
|
||||
},
|
||||
get unlockQueue() {
|
||||
return unlockQueue;
|
||||
},
|
||||
|
||||
initialize,
|
||||
reinitialize,
|
||||
seedIfEmpty,
|
||||
checkLocal,
|
||||
handleApiUnlocks,
|
||||
popUnlockQueue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Skills Store — Local-First with @manacore/local-store
|
||||
* Skills Store — Write Actions Only
|
||||
*
|
||||
* All reads and writes go to IndexedDB (Dexie.js) first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Reads are handled by useLiveQuery hooks in queries.ts.
|
||||
* This store only exposes mutation actions that write to IndexedDB.
|
||||
*/
|
||||
|
||||
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
|
||||
import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types';
|
||||
import type { Skill, Activity } from '$lib/types';
|
||||
import { calculateLevel, createDefaultSkill, createActivity } from '$lib/types';
|
||||
import { SkillTreeEvents } from '@manacore/shared-utils/analytics';
|
||||
import {
|
||||
skillCollection,
|
||||
|
|
@ -14,118 +14,9 @@ import {
|
|||
type LocalSkill,
|
||||
type LocalActivity,
|
||||
} from '$lib/data/local-store';
|
||||
import { achievementStore } from './achievements.svelte';
|
||||
|
||||
// Reactive state using Svelte 5 runes
|
||||
let skills = $state<Skill[]>([]);
|
||||
let activities = $state<Activity[]>([]);
|
||||
let userStats = $state<UserStats>({
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
});
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
// ─── Converters ──────────────────────────────────────────────
|
||||
|
||||
function toSkill(local: LocalSkill): Skill {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description,
|
||||
branch: local.branch,
|
||||
parentId: local.parentId ?? null,
|
||||
icon: local.icon,
|
||||
color: local.color ?? null,
|
||||
currentXp: local.currentXp,
|
||||
totalXp: local.totalXp,
|
||||
level: local.level,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toActivity(local: LocalActivity): Activity {
|
||||
return {
|
||||
id: local.id,
|
||||
skillId: local.skillId,
|
||||
xpEarned: local.xpEarned,
|
||||
description: local.description,
|
||||
duration: local.duration ?? null,
|
||||
timestamp: local.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Derived values ──────────────────────────────────────────
|
||||
|
||||
const skillsByBranch = $derived(() => {
|
||||
const grouped: Record<SkillBranch, Skill[]> = {
|
||||
intellect: [],
|
||||
body: [],
|
||||
creativity: [],
|
||||
social: [],
|
||||
practical: [],
|
||||
mindset: [],
|
||||
custom: [],
|
||||
};
|
||||
for (const skill of skills) {
|
||||
grouped[skill.branch].push(skill);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const topSkills = $derived(() => {
|
||||
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
|
||||
});
|
||||
|
||||
const recentActivities = $derived(() => {
|
||||
return [...activities]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
const branchStats = $derived(() => {
|
||||
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> =
|
||||
{} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
|
||||
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
|
||||
const branchSkills = skills.filter((s) => s.branch === branch);
|
||||
stats[branch] = {
|
||||
count: branchSkills.length,
|
||||
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
avgLevel:
|
||||
branchSkills.length > 0
|
||||
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
async function initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const [localSkills, localActivities] = await Promise.all([
|
||||
skillCollection.getAll(),
|
||||
activityCollection.getAll(),
|
||||
]);
|
||||
skills = localSkills.map(toSkill);
|
||||
activities = localActivities.map(toActivity);
|
||||
recalculateStats();
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skills store:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addSkill(data: Partial<Skill>): Promise<Skill> {
|
||||
const skill = createDefaultSkill(data);
|
||||
const localSkill: LocalSkill = {
|
||||
|
|
@ -141,16 +32,11 @@ async function addSkill(data: Partial<Skill>): Promise<Skill> {
|
|||
level: skill.level,
|
||||
};
|
||||
await skillCollection.insert(localSkill);
|
||||
skills = [...skills, skill];
|
||||
SkillTreeEvents.skillCreated(data.branch || 'custom');
|
||||
recalculateStats();
|
||||
return skill;
|
||||
}
|
||||
|
||||
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
|
||||
const index = skills.findIndex((s) => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const localUpdates: Partial<LocalSkill> = {};
|
||||
if (updates.name !== undefined) localUpdates.name = updates.name;
|
||||
if (updates.description !== undefined) localUpdates.description = updates.description;
|
||||
|
|
@ -160,9 +46,6 @@ async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
|
|||
if (updates.color !== undefined) localUpdates.color = updates.color;
|
||||
|
||||
await skillCollection.update(id, localUpdates);
|
||||
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
recalculateStats();
|
||||
}
|
||||
|
||||
async function deleteSkill(id: string): Promise<void> {
|
||||
|
|
@ -172,10 +55,7 @@ async function deleteSkill(id: string): Promise<void> {
|
|||
await activityCollection.delete(a.id);
|
||||
}
|
||||
await skillCollection.delete(id);
|
||||
skills = skills.filter((s) => s.id !== id);
|
||||
activities = activities.filter((a) => a.skillId !== id);
|
||||
SkillTreeEvents.skillDeleted();
|
||||
recalculateStats();
|
||||
}
|
||||
|
||||
async function addXp(
|
||||
|
|
@ -184,10 +64,10 @@ async function addXp(
|
|||
description: string,
|
||||
duration?: number
|
||||
): Promise<{ leveledUp: boolean; newLevel: number }> {
|
||||
const index = skills.findIndex((s) => s.id === skillId);
|
||||
if (index === -1) return { leveledUp: false, newLevel: 0 };
|
||||
const existing = await skillCollection.getAll({ id: skillId });
|
||||
const skill = existing.find((s) => s.id === skillId);
|
||||
if (!skill) return { leveledUp: false, newLevel: 0 };
|
||||
|
||||
const skill = skills[index];
|
||||
const newTotalXp = skill.totalXp + xp;
|
||||
const newCurrentXp = skill.currentXp + xp;
|
||||
const newLevel = calculateLevel(newTotalXp);
|
||||
|
|
@ -210,124 +90,15 @@ async function addXp(
|
|||
};
|
||||
await activityCollection.insert(localActivity);
|
||||
|
||||
const updatedSkill: Skill = {
|
||||
...skill,
|
||||
totalXp: newTotalXp,
|
||||
currentXp: newCurrentXp,
|
||||
level: newLevel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
activities = [...activities, activity];
|
||||
SkillTreeEvents.xpAdded(xp, leveledUp);
|
||||
recalculateStats();
|
||||
|
||||
return { leveledUp, newLevel };
|
||||
}
|
||||
|
||||
function recalculateStats(): void {
|
||||
const sortedActivities = [...activities].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
userStats = {
|
||||
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
totalSkills: skills.length,
|
||||
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
|
||||
streakDays: calculateStreak(activities),
|
||||
lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateStreak(activityList: Activity[]): number {
|
||||
if (activityList.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const sortedDates = activityList
|
||||
.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of sortedDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
function getSkill(id: string): Skill | undefined {
|
||||
return skills.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function getSkillActivities(skillId: string): Activity[] {
|
||||
return activities.filter((a) => a.skillId === skillId);
|
||||
}
|
||||
|
||||
async function reinitialize() {
|
||||
initialized = false;
|
||||
skills = [];
|
||||
activities = [];
|
||||
userStats = {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
};
|
||||
await initialize();
|
||||
}
|
||||
|
||||
// Export store
|
||||
// Export store (write-only actions)
|
||||
export const skillStore = {
|
||||
get skills() {
|
||||
return skills;
|
||||
},
|
||||
get activities() {
|
||||
return activities;
|
||||
},
|
||||
get userStats() {
|
||||
return userStats;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
get skillsByBranch() {
|
||||
return skillsByBranch;
|
||||
},
|
||||
get topSkills() {
|
||||
return topSkills;
|
||||
},
|
||||
get recentActivities() {
|
||||
return recentActivities;
|
||||
},
|
||||
get branchStats() {
|
||||
return branchStats;
|
||||
},
|
||||
|
||||
initialize,
|
||||
reinitialize,
|
||||
addSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
addXp,
|
||||
getSkill,
|
||||
getSkillActivities,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
|
|
@ -18,9 +17,8 @@
|
|||
if (authStore.isAuthenticated) {
|
||||
skilltreeStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
// Load data from IndexedDB into reactive stores
|
||||
await skillStore.initialize();
|
||||
await achievementStore.initialize();
|
||||
// Seed achievement definitions into IndexedDB if first run
|
||||
await achievementStore.seedIfEmpty();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import {
|
||||
useAllSkills,
|
||||
useAllActivities,
|
||||
useAllAchievements,
|
||||
filterByBranch,
|
||||
getRecentActivities,
|
||||
getSkillById,
|
||||
computeUserStats,
|
||||
} from '$lib/data/queries';
|
||||
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import type { Skill, SkillBranch, AchievementUnlockResult } from '$lib/types';
|
||||
import SkillCard from '$lib/components/SkillCard.svelte';
|
||||
|
|
@ -22,6 +32,18 @@
|
|||
Trophy,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
// Reactive live queries — auto-update when IndexedDB changes
|
||||
const allSkills = useAllSkills();
|
||||
const allActivities = useAllActivities();
|
||||
const allAchievementsRaw = useAllAchievements();
|
||||
|
||||
// Derived values from live queries
|
||||
const skills = $derived(allSkills.value);
|
||||
const activities = $derived(allActivities.value);
|
||||
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
|
||||
const achievementStats = $derived(getAchievementStats(achievements));
|
||||
const userStats = $derived(computeUserStats(skills, activities));
|
||||
|
||||
// Modal states
|
||||
let showAddSkillModal = $state(false);
|
||||
let showAddXpModal = $state(false);
|
||||
|
|
@ -39,10 +61,7 @@
|
|||
let showAchievementCelebration = $state(false);
|
||||
let currentAchievementUnlock = $state<AchievementUnlockResult | null>(null);
|
||||
|
||||
const filteredSkills = $derived(() => {
|
||||
if (selectedBranch === 'all') return skillStore.skills;
|
||||
return skillStore.skills.filter((s) => s.branch === selectedBranch);
|
||||
});
|
||||
const filteredSkills = $derived(filterByBranch(skills, selectedBranch));
|
||||
|
||||
function openAddXpModal(skill: Skill) {
|
||||
selectedSkill = skill;
|
||||
|
|
@ -78,15 +97,13 @@
|
|||
}
|
||||
|
||||
async function checkAchievementsLocal(lastActivityXp?: number) {
|
||||
if (!achievementStore.useApi) {
|
||||
await achievementStore.checkLocal({
|
||||
skills: skillStore.skills,
|
||||
activities: skillStore.activities,
|
||||
userStats: skillStore.userStats,
|
||||
lastActivityXp,
|
||||
});
|
||||
showNextAchievement();
|
||||
}
|
||||
await achievementStore.checkLocal({
|
||||
skills,
|
||||
activities,
|
||||
userStats,
|
||||
lastActivityXp,
|
||||
});
|
||||
showNextAchievement();
|
||||
}
|
||||
|
||||
async function handleAddXp(xp: number, description: string, duration?: number) {
|
||||
|
|
@ -158,11 +175,11 @@
|
|||
title="Achievements"
|
||||
>
|
||||
<Trophy class="h-5 w-5" />
|
||||
{#if achievementStore.stats().unlocked > 0}
|
||||
{#if achievementStats.unlocked > 0}
|
||||
<span
|
||||
class="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-[10px] font-bold text-gray-900"
|
||||
>
|
||||
{achievementStore.stats().unlocked}
|
||||
{achievementStats.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
|
@ -224,10 +241,10 @@
|
|||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
Alle ({skillStore.skills.length})
|
||||
Alle ({skills.length})
|
||||
</button>
|
||||
{#each Object.entries(BRANCH_INFO) as [branch, info]}
|
||||
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
|
||||
{@const count = skills.filter((s) => s.branch === branch).length}
|
||||
{#if count > 0 || branch !== 'custom'}
|
||||
<button
|
||||
onclick={() => (selectedBranch = branch as SkillBranch)}
|
||||
|
|
@ -244,7 +261,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Skills Grid -->
|
||||
{#if filteredSkills().length === 0}
|
||||
{#if filteredSkills.length === 0}
|
||||
<div class="mt-16 text-center">
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800"
|
||||
|
|
@ -263,7 +280,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredSkills() as skill (skill.id)}
|
||||
{#each filteredSkills as skill (skill.id)}
|
||||
<SkillCard
|
||||
{skill}
|
||||
onAddXp={() => openAddXpModal(skill)}
|
||||
|
|
@ -275,15 +292,15 @@
|
|||
{/if}
|
||||
|
||||
<!-- Recent Activity -->
|
||||
{#if skillStore.recentActivities().length > 0}
|
||||
{#if getRecentActivities(activities).length > 0}
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Lightning class="h-5 w-5 text-yellow-500" />
|
||||
Letzte Aktivitäten
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each skillStore.recentActivities().slice(0, 5) as activity}
|
||||
{@const skill = skillStore.getSkill(activity.skillId)}
|
||||
{#each getRecentActivities(activities).slice(0, 5) as activity}
|
||||
{@const skill = getSkillById(skills, activity.skillId)}
|
||||
{#if skill}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { useAllAchievements } from '$lib/data/queries';
|
||||
import {
|
||||
buildAchievementStatus,
|
||||
getAchievementStats,
|
||||
getCompletionPercentage,
|
||||
} from '$lib/stores/achievements.svelte';
|
||||
import { ACHIEVEMENT_CATEGORY_INFO, RARITY_INFO } from '$lib/types';
|
||||
import type { AchievementCategory } from '$lib/types';
|
||||
import AchievementCard from '$lib/components/AchievementCard.svelte';
|
||||
import { ArrowLeft, Trophy, Star } from '@manacore/shared-icons';
|
||||
|
||||
// Reactive live query
|
||||
const allAchievementsRaw = useAllAchievements();
|
||||
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
|
||||
const stats = $derived(getAchievementStats(achievements));
|
||||
const completion = $derived(getCompletionPercentage(achievements));
|
||||
|
||||
let selectedCategory = $state<AchievementCategory | 'all'>('all');
|
||||
let showOnlyUnlocked = $state(false);
|
||||
|
||||
const filteredAchievements = $derived(() => {
|
||||
let list = achievementStore.achievements;
|
||||
let list = achievements;
|
||||
if (selectedCategory !== 'all') {
|
||||
list = list.filter((a) => a.category === selectedCategory);
|
||||
}
|
||||
|
|
@ -46,7 +57,7 @@
|
|||
<div class="flex items-center gap-2 rounded-full bg-yellow-500/10 px-4 py-2">
|
||||
<Trophy class="h-4 w-4 text-yellow-400" />
|
||||
<span class="font-semibold text-yellow-400">
|
||||
{achievementStore.stats().unlocked} / {achievementStore.stats().total}
|
||||
{stats.unlocked} / {stats.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,22 +70,18 @@
|
|||
<div class="mb-8 rounded-xl border border-gray-700 bg-gray-800/50 p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-white">Fortschritt</h2>
|
||||
<span class="text-2xl font-bold text-yellow-400"
|
||||
>{achievementStore.completionPercentage()}%</span
|
||||
>
|
||||
<span class="text-2xl font-bold text-yellow-400">{completion}%</span>
|
||||
</div>
|
||||
<div class="h-3 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-500"
|
||||
style="width: {achievementStore.completionPercentage()}%"
|
||||
style="width: {completion}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-4 text-sm">
|
||||
{#each Object.entries(RARITY_INFO) as [rarity, info]}
|
||||
{@const count = achievementStore.achievements.filter(
|
||||
(a) => a.rarity === rarity && a.unlocked
|
||||
).length}
|
||||
{@const total = achievementStore.achievements.filter((a) => a.rarity === rarity).length}
|
||||
{@const count = achievements.filter((a) => a.rarity === rarity && a.unlocked).length}
|
||||
{@const total = achievements.filter((a) => a.rarity === rarity).length}
|
||||
<span class="flex items-center gap-1.5 {info.color}">
|
||||
<Star class="h-3 w-3" />
|
||||
{info.name}: {count}/{total}
|
||||
|
|
@ -92,10 +99,10 @@
|
|||
? 'bg-yellow-500 text-gray-900'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
Alle ({achievementStore.achievements.length})
|
||||
Alle ({achievements.length})
|
||||
</button>
|
||||
{#each categoryEntries as [category, info]}
|
||||
{@const count = achievementStore.achievements.filter((a) => a.category === category).length}
|
||||
{@const count = achievements.filter((a) => a.category === category).length}
|
||||
<button
|
||||
onclick={() => (selectedCategory = category)}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { useAllSkills } from '$lib/data/queries';
|
||||
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
|
||||
import type { SkillBranch } from '$lib/types';
|
||||
import { ArrowLeft, Star } from '@manacore/shared-icons';
|
||||
|
||||
// Reactive live query
|
||||
const allSkills = useAllSkills();
|
||||
const skills = $derived(allSkills.value);
|
||||
|
||||
// Group skills by branch for radial layout
|
||||
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
|
||||
|
||||
|
|
@ -74,7 +78,7 @@
|
|||
</header>
|
||||
|
||||
<main class="p-4">
|
||||
{#if skillStore.skills.length === 0}
|
||||
{#if skills.length === 0}
|
||||
<div class="mt-16 text-center">
|
||||
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
|
||||
<a
|
||||
|
|
@ -88,7 +92,7 @@
|
|||
<!-- Legend -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4">
|
||||
{#each Object.entries(BRANCH_INFO) as [branch, info]}
|
||||
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
|
||||
{@const count = skills.filter((s) => s.branch === branch).length}
|
||||
{#if count > 0}
|
||||
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
|
||||
|
|
@ -145,7 +149,7 @@
|
|||
<!-- Branch lines and labels -->
|
||||
{#each branches as branch, i}
|
||||
{@const pos = getBranchPosition(i, branches.length)}
|
||||
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
|
||||
{@const branchSkills = skills.filter((s) => s.branch === branch)}
|
||||
{#if branchSkills.length > 0}
|
||||
<!-- Line from center to branch -->
|
||||
<line
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY services/mana-api-gateway-go/go.mod services/mana-api-gateway-go/go.sum ./
|
||||
COPY services/mana-api-gateway/go.mod services/mana-api-gateway/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY services/mana-api-gateway-go/ .
|
||||
COPY services/mana-api-gateway/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-api-gateway ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ module github.com/manacore/mana-api-gateway
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/manacore/shared-go v0.0.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
|
|
@ -19,3 +20,5 @@ require (
|
|||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/manacore/shared-go => ../../packages/shared-go
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/manacore/shared-go/envutil"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -30,64 +30,44 @@ type Config struct {
|
|||
KeyPrefixTest string
|
||||
|
||||
// Defaults
|
||||
DefaultRateLimit int
|
||||
DefaultRateLimit int
|
||||
DefaultMonthlyCredits int
|
||||
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
port, _ := strconv.Atoi(getEnv("PORT", "3030"))
|
||||
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
|
||||
defaultRL, _ := strconv.Atoi(getEnv("DEFAULT_RATE_LIMIT", "10"))
|
||||
defaultCredits, _ := strconv.Atoi(getEnv("DEFAULT_MONTHLY_CREDITS", "100"))
|
||||
|
||||
// Parse admin IDs (trim whitespace)
|
||||
var adminIDs []string
|
||||
if ids := os.Getenv("ADMIN_USER_IDS"); ids != "" {
|
||||
for _, id := range strings.Split(ids, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
adminIDs = append(adminIDs, id)
|
||||
}
|
||||
for _, id := range envutil.GetSlice("ADMIN_USER_IDS", nil) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
adminIDs = append(adminIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
var origins []string
|
||||
if o := os.Getenv("CORS_ORIGINS"); o != "" {
|
||||
for _, origin := range strings.Split(o, ",") {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin != "" {
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
origins = []string{"http://localhost:3000", "http://localhost:5173"}
|
||||
// Parse CORS origins (trim whitespace)
|
||||
origins := envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"})
|
||||
for i, o := range origins {
|
||||
origins[i] = strings.TrimSpace(o)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: redisPort,
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisPrefix: getEnv("REDIS_PREFIX", "api-gateway:"),
|
||||
SearchURL: getEnv("SEARCH_SERVICE_URL", "http://localhost:3021"),
|
||||
STTURL: getEnv("STT_SERVICE_URL", "http://localhost:3020"),
|
||||
TTSURL: getEnv("TTS_SERVICE_URL", "http://localhost:3022"),
|
||||
AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"),
|
||||
Port: envutil.GetInt("PORT", 3030),
|
||||
DatabaseURL: envutil.Get("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
|
||||
RedisHost: envutil.Get("REDIS_HOST", "localhost"),
|
||||
RedisPort: envutil.GetInt("REDIS_PORT", 6379),
|
||||
RedisPassword: envutil.Get("REDIS_PASSWORD", ""),
|
||||
RedisPrefix: envutil.Get("REDIS_PREFIX", "api-gateway:"),
|
||||
SearchURL: envutil.Get("SEARCH_SERVICE_URL", "http://localhost:3021"),
|
||||
STTURL: envutil.Get("STT_SERVICE_URL", "http://localhost:3020"),
|
||||
TTSURL: envutil.Get("TTS_SERVICE_URL", "http://localhost:3022"),
|
||||
AuthURL: envutil.Get("MANA_CORE_AUTH_URL", "http://localhost:3001"),
|
||||
AdminUserIDs: adminIDs,
|
||||
KeyPrefixLive: getEnv("API_KEY_PREFIX_LIVE", "sk_live_"),
|
||||
KeyPrefixTest: getEnv("API_KEY_PREFIX_TEST", "sk_test_"),
|
||||
DefaultRateLimit: defaultRL,
|
||||
DefaultMonthlyCredits: defaultCredits,
|
||||
KeyPrefixLive: envutil.Get("API_KEY_PREFIX_LIVE", "sk_live_"),
|
||||
KeyPrefixTest: envutil.Get("API_KEY_PREFIX_TEST", "sk_test_"),
|
||||
DefaultRateLimit: envutil.GetInt("DEFAULT_RATE_LIMIT", 10),
|
||||
DefaultMonthlyCredits: envutil.GetInt("DEFAULT_MONTHLY_CREDITS", 100),
|
||||
CORSOrigins: origins,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/shared-go/httputil"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/middleware"
|
||||
|
|
@ -24,7 +26,7 @@ func NewApiKeysHandler(apiKeySvc *service.ApiKeyService, usageSvc *service.Usage
|
|||
func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
httputil.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -35,12 +37,12 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
|
|||
IsTest bool `json:"isTest"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -50,11 +52,11 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
rawKey, apiKey, err := h.apiKeyService.Create(r.Context(), userID, body.Name, body.Description, body.Tier, body.IsTest)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"key": rawKey,
|
||||
"apiKey": apiKey,
|
||||
})
|
||||
|
|
@ -64,13 +66,13 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
httputil.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := h.apiKeyService.ListByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +80,7 @@ func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
|||
keys = []service.ApiKey{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
httputil.WriteJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// DeleteKey handles DELETE /api-keys/{id}
|
||||
|
|
@ -87,11 +89,11 @@ func (h *ApiKeysHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
|
|||
keyID := r.PathValue("id")
|
||||
|
||||
if err := h.apiKeyService.Delete(r.Context(), keyID, userID); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
|
||||
httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
|
||||
}
|
||||
|
||||
// GetUsage handles GET /api-keys/{id}/usage
|
||||
|
|
@ -100,7 +102,7 @@ func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
usage, err := h.usageService.GetDailyUsage(r.Context(), keyID, 30)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +110,7 @@ func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
|||
usage = []service.DailyUsage{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, usage)
|
||||
httputil.WriteJSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// GetUsageSummary handles GET /api-keys/{id}/usage/summary
|
||||
|
|
@ -118,15 +120,10 @@ func (h *ApiKeysHandler) GetUsageSummary(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
summary, err := h.usageService.GetSummary(r.Context(), keyID, since)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
httputil.WriteJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package handler
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/shared-go/httputil"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
|
@ -40,7 +42,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|||
status = "degraded"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"service": "mana-api-gateway",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY services/mana-crawler-go/go.mod services/mana-crawler-go/go.sum ./
|
||||
COPY services/mana-crawler/go.mod services/mana-crawler/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY services/mana-crawler-go/ .
|
||||
COPY services/mana-crawler/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-crawler ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ module github.com/manacore/mana-crawler
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/manacore/shared-go v0.0.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/rs/cors v1.11.1
|
||||
|
|
@ -18,3 +19,5 @@ require (
|
|||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/manacore/shared-go => ../../packages/shared-go
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/manacore/shared-go/envutil"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -14,51 +14,31 @@ type Config struct {
|
|||
RedisPort int
|
||||
RedisPassword string
|
||||
|
||||
UserAgent string
|
||||
UserAgent string
|
||||
DefaultRateLimit float64
|
||||
DefaultMaxDepth int
|
||||
DefaultMaxPages int
|
||||
Timeout int // ms
|
||||
Concurrency int
|
||||
DefaultMaxDepth int
|
||||
DefaultMaxPages int
|
||||
Timeout int // ms
|
||||
Concurrency int
|
||||
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
port, _ := strconv.Atoi(getEnv("PORT", "3023"))
|
||||
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
|
||||
rateLimit, _ := strconv.ParseFloat(getEnv("CRAWLER_DEFAULT_RATE_LIMIT", "2"), 64)
|
||||
maxDepth, _ := strconv.Atoi(getEnv("CRAWLER_DEFAULT_MAX_DEPTH", "3"))
|
||||
maxPages, _ := strconv.Atoi(getEnv("CRAWLER_DEFAULT_MAX_PAGES", "100"))
|
||||
timeout, _ := strconv.Atoi(getEnv("CRAWLER_TIMEOUT", "30000"))
|
||||
concurrency, _ := strconv.Atoi(getEnv("QUEUE_CONCURRENCY", "5"))
|
||||
|
||||
var origins []string
|
||||
if o := os.Getenv("CORS_ORIGINS"); o != "" {
|
||||
origins = strings.Split(o, ",")
|
||||
} else {
|
||||
origins = []string{"http://localhost:3000", "http://localhost:5173"}
|
||||
}
|
||||
rateLimit, _ := strconv.ParseFloat(envutil.Get("CRAWLER_DEFAULT_RATE_LIMIT", "2"), 64)
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: redisPort,
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
UserAgent: getEnv("CRAWLER_USER_AGENT", "ManaCoreCrawler/1.0 (+https://manacore.io/bot)"),
|
||||
Port: envutil.GetInt("PORT", 3023),
|
||||
DatabaseURL: envutil.Get("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
|
||||
RedisHost: envutil.Get("REDIS_HOST", "localhost"),
|
||||
RedisPort: envutil.GetInt("REDIS_PORT", 6379),
|
||||
RedisPassword: envutil.Get("REDIS_PASSWORD", ""),
|
||||
UserAgent: envutil.Get("CRAWLER_USER_AGENT", "ManaCoreCrawler/1.0 (+https://manacore.io/bot)"),
|
||||
DefaultRateLimit: rateLimit,
|
||||
DefaultMaxDepth: maxDepth,
|
||||
DefaultMaxPages: maxPages,
|
||||
Timeout: timeout,
|
||||
Concurrency: concurrency,
|
||||
CORSOrigins: origins,
|
||||
DefaultMaxDepth: envutil.GetInt("CRAWLER_DEFAULT_MAX_DEPTH", 3),
|
||||
DefaultMaxPages: envutil.GetInt("CRAWLER_DEFAULT_MAX_PAGES", 100),
|
||||
Timeout: envutil.GetInt("CRAWLER_TIMEOUT", 30000),
|
||||
Concurrency: envutil.GetInt("QUEUE_CONCURRENCY", 5),
|
||||
CORSOrigins: envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"}),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/shared-go/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -33,18 +35,18 @@ func (h *Handler) StartCrawl(w http.ResponseWriter, r *http.Request) {
|
|||
WebhookURL string `json:"webhookUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.StartURL == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "startUrl is required"})
|
||||
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "startUrl is required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(body.StartURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid URL"})
|
||||
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid URL"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -86,17 +88,17 @@ func (h *Handler) StartCrawl(w http.ResponseWriter, r *http.Request) {
|
|||
string(configJSON), fmt.Sprintf(`{"format":"%s"}`, cfg.OutputFormat)).Scan(&jobID)
|
||||
if err != nil {
|
||||
slog.Error("create job failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create job"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create job"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start crawl (use background context so it outlives the HTTP request)
|
||||
if err := h.crawler.StartJob(context.Background(), jobID, body.StartURL, cfg); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start crawl"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start crawl"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"jobId": jobID,
|
||||
"status": "running",
|
||||
"startUrl": body.StartURL,
|
||||
|
|
@ -126,11 +128,11 @@ func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
|
|||
FROM crawler.crawl_jobs WHERE id = $1
|
||||
`, jobID).Scan(&job.ID, &job.StartURL, &job.Domain, &job.Status, &job.Progress, &job.Error, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "job not found"})
|
||||
httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, job)
|
||||
httputil.WriteJSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
// GetJobResults handles GET /api/v1/crawl/{jobId}/results
|
||||
|
|
@ -155,7 +157,7 @@ func (h *Handler) GetJobResults(w http.ResponseWriter, r *http.Request) {
|
|||
FROM crawler.crawl_results WHERE job_id = $1 ORDER BY created_at LIMIT $2 OFFSET $3
|
||||
`, jobID, limit, offset)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
|
@ -180,7 +182,7 @@ func (h *Handler) GetJobResults(w http.ResponseWriter, r *http.Request) {
|
|||
results = []map[string]any{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"results": results,
|
||||
"pagination": map[string]any{
|
||||
"page": page, "limit": limit, "total": total,
|
||||
|
|
@ -218,7 +220,7 @@ func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
rows, err := h.pool.Query(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
|
||||
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
|
@ -237,7 +239,7 @@ func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
|||
jobs = []map[string]any{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"results": jobs,
|
||||
"pagination": map[string]any{
|
||||
"page": page, "limit": limit, "total": total,
|
||||
|
|
@ -263,7 +265,7 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
|
|||
if dbOK != "ok" {
|
||||
status = "degraded"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status, "service": "mana-crawler", "database": dbOK,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
|
@ -284,8 +286,3 @@ func (h *Handler) Metrics(w http.ResponseWriter, r *http.Request) {
|
|||
fmt.Fprintf(w, "mana_crawler_jobs{status=\"failed\"} %d\n", failed)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY services/mana-notify-go/go.mod services/mana-notify-go/go.sum ./
|
||||
COPY services/mana-notify/go.mod services/mana-notify/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY services/mana-notify-go/ .
|
||||
COPY services/mana-notify/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-notify ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY services/mana-search-go/go.mod services/mana-search-go/go.sum ./
|
||||
COPY services/mana-search/go.mod services/mana-search/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY services/mana-search-go/ .
|
||||
COPY services/mana-search/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-search ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue