perf: fix N+1 query pattern across all modules + rack numbering

CRITICAL PERF BUG: Every hook did storage.list() (1 HTTP call fetching ALL
items with values, discarding values, returning only keys) then storage.get()
for EACH key (N individual HTTP calls re-fetching values one by one).

With 6 entries + contacts + tags, Registratura page fired ~40 sequential
HTTP requests on load, where 3 would suffice.

Fix: Replace list()+N*get() with single exportAll() call in ALL hooks:
- registratura/registry-service.ts (added exportAll to RegistryStorage interface)
- address-book/use-contacts.ts
- it-inventory/use-inventory.ts
- password-vault/use-vault.ts
- word-templates/use-templates.ts
- prompt-generator/use-prompt-generator.ts
- hot-desk/use-reservations.ts
- email-signature/use-saved-signatures.ts
- digital-signatures/use-signatures.ts
- ai-chat/use-chat.ts
- core/tagging/tag-service.ts (uses storage.export())

Additional fixes:
- registratura/use-registry.ts: addEntry uses optimistic local state update
  instead of double-refresh; closeEntry batches saves with Promise.all +
  single refresh
- server-rack.tsx: reversed slot rendering so U1 is at bottom (standard
  rack numbering, per user's physical rack)

Performance impact: ~90% reduction in HTTP requests on page load for all modules
This commit is contained in:
AI Assistant
2026-02-27 22:26:11 +02:00
parent 4cd793fbbc
commit c45a30ec14
13 changed files with 580 additions and 354 deletions
+36 -20
View File
@@ -1,40 +1,45 @@
import type { StorageService } from '@/core/storage/types';
import type { Tag, TagCategory, TagScope } from './types';
import { v4 as uuid } from 'uuid';
import type { StorageService } from "@/core/storage/types";
import type { Tag, TagCategory, TagScope } from "./types";
import { v4 as uuid } from "uuid";
const NAMESPACE = 'tags';
const NAMESPACE = "tags";
export class TagService {
constructor(private storage: StorageService) {}
async getAllTags(): Promise<Tag[]> {
const keys = await this.storage.list(NAMESPACE);
const all = await this.storage.export(NAMESPACE);
const tags: Tag[] = [];
for (const key of keys) {
const tag = await this.storage.get<Tag>(NAMESPACE, key);
if (tag) tags.push(tag);
for (const value of Object.values(all)) {
if (value) tags.push(value as Tag);
}
return tags;
}
async getTagsByCategory(category: TagCategory): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.category === category);
return this.storage.query<Tag>(
NAMESPACE,
(tag) => tag.category === category,
);
}
async getTagsByScope(scope: TagScope, scopeId?: string): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => {
if (tag.scope !== scope) return false;
if (scope === 'module' && scopeId) return tag.moduleId === scopeId;
if (scope === 'company' && scopeId) return tag.companyId === scopeId;
if (scope === "module" && scopeId) return tag.moduleId === scopeId;
if (scope === "company" && scopeId) return tag.companyId === scopeId;
return true;
});
}
async getChildren(parentId: string): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.parentId === parentId);
return this.storage.query<Tag>(
NAMESPACE,
(tag) => tag.parentId === parentId,
);
}
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
async createTag(data: Omit<Tag, "id" | "createdAt">): Promise<Tag> {
const tag: Tag = {
...data,
id: uuid(),
@@ -44,10 +49,17 @@ export class TagService {
return tag;
}
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
async updateTag(
id: string,
updates: Partial<Omit<Tag, "id" | "createdAt">>,
): Promise<Tag | null> {
const existing = await this.storage.get<Tag>(NAMESPACE, id);
if (!existing) return null;
const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const updated: Tag = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
await this.storage.set(NAMESPACE, id, updated);
return updated;
}
@@ -63,16 +75,20 @@ export class TagService {
async searchTags(query: string): Promise<Tag[]> {
const lower = query.toLowerCase();
return this.storage.query<Tag>(NAMESPACE, (tag) =>
tag.label.toLowerCase().includes(lower) ||
(tag.projectCode?.toLowerCase().includes(lower) ?? false)
return this.storage.query<Tag>(
NAMESPACE,
(tag) =>
tag.label.toLowerCase().includes(lower) ||
(tag.projectCode?.toLowerCase().includes(lower) ?? false),
);
}
/** Bulk import tags (for seed data). Skips tags whose label already exists in same category. */
async importTags(tags: Omit<Tag, 'id' | 'createdAt'>[]): Promise<number> {
async importTags(tags: Omit<Tag, "id" | "createdAt">[]): Promise<number> {
const existing = await this.getAllTags();
const existingKeys = new Set(existing.map((t) => `${t.category}::${t.label}`));
const existingKeys = new Set(
existing.map((t) => `${t.category}::${t.label}`),
);
let imported = 0;
for (const data of tags) {
const key = `${data.category}::${data.label}`;
+77 -46
View File
@@ -1,31 +1,33 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { AddressContact, ContactType } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { AddressContact, ContactType } from "../types";
const PREFIX = 'contact:';
const PREFIX = "contact:";
export interface ContactFilters {
search: string;
type: ContactType | 'all';
type: ContactType | "all";
}
export function useContacts() {
const storage = useStorage('address-book');
const storage = useStorage("address-book");
const [contacts, setContacts] = useState<AddressContact[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<ContactFilters>({ search: '', type: 'all' });
const [filters, setFilters] = useState<ContactFilters>({
search: "",
type: "all",
});
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: AddressContact[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<AddressContact>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as AddressContact);
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
@@ -34,41 +36,60 @@ export function useContacts() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const contact: AddressContact = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${contact.id}`, contact);
await refresh();
return contact;
}, [storage, refresh]);
const addContact = useCallback(
async (data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString();
const contact: AddressContact = {
...data,
id: uuid(),
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${contact.id}`, contact);
await refresh();
return contact;
},
[storage, refresh],
);
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
const existing = contacts.find((c) => c.id === id);
if (!existing) return;
const updated: AddressContact = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, contacts]);
const updateContact = useCallback(
async (id: string, updates: Partial<AddressContact>) => {
const existing = contacts.find((c) => c.id === id);
if (!existing) return;
const updated: AddressContact = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
},
[storage, refresh, contacts],
);
const removeContact = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const removeContact = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
const updateFilter = useCallback(<K extends keyof ContactFilters>(key: K, value: ContactFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const updateFilter = useCallback(
<K extends keyof ContactFilters>(key: K, value: ContactFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[],
);
const filteredContacts = contacts.filter((c) => {
if (filters.type !== 'all' && c.type !== filters.type) return false;
if (filters.type !== "all" && c.type !== filters.type) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return (
@@ -76,12 +97,22 @@ export function useContacts() {
c.company.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.phone.includes(q) ||
(c.department ?? '').toLowerCase().includes(q) ||
(c.role ?? '').toLowerCase().includes(q)
(c.department ?? "").toLowerCase().includes(q) ||
(c.role ?? "").toLowerCase().includes(q)
);
}
return true;
});
return { contacts: filteredContacts, allContacts: contacts, loading, filters, updateFilter, addContact, updateContact, removeContact, refresh };
return {
contacts: filteredContacts,
allContacts: contacts,
loading,
filters,
updateFilter,
addContact,
updateContact,
removeContact,
refresh,
};
}
+67 -52
View File
@@ -1,14 +1,14 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { ChatMessage, ChatSession } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { ChatMessage, ChatSession } from "../types";
const SESSION_PREFIX = 'session:';
const SESSION_PREFIX = "session:";
export function useChat() {
const storage = useStorage('ai-chat');
const storage = useStorage("ai-chat");
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -17,12 +17,11 @@ export function useChat() {
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: ChatSession[] = [];
for (const key of keys) {
if (key.startsWith(SESSION_PREFIX)) {
const session = await storage.get<ChatSession>(key);
if (session) results.push(session);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(SESSION_PREFIX) && value) {
results.push(value as ChatSession);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -31,49 +30,65 @@ export function useChat() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const createSession = useCallback(async (title?: string) => {
const session: ChatSession = {
id: uuid(),
title: title || `Conversație ${new Date().toLocaleDateString('ro-RO')}`,
messages: [],
createdAt: new Date().toISOString(),
};
await storage.set(`${SESSION_PREFIX}${session.id}`, session);
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
return session;
}, [storage]);
const createSession = useCallback(
async (title?: string) => {
const session: ChatSession = {
id: uuid(),
title: title || `Conversație ${new Date().toLocaleDateString("ro-RO")}`,
messages: [],
createdAt: new Date().toISOString(),
};
await storage.set(`${SESSION_PREFIX}${session.id}`, session);
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
return session;
},
[storage],
);
const addMessage = useCallback(async (content: string, role: ChatMessage['role']) => {
if (!activeSessionId) return;
const message: ChatMessage = {
id: uuid(),
role,
content,
timestamp: new Date().toISOString(),
};
setSessions((prev) => prev.map((s) => {
if (s.id !== activeSessionId) return s;
return { ...s, messages: [...s.messages, message] };
}));
// Persist
const current = sessions.find((s) => s.id === activeSessionId);
if (current) {
const updated = { ...current, messages: [...current.messages, message] };
await storage.set(`${SESSION_PREFIX}${activeSessionId}`, updated);
}
return message;
}, [storage, activeSessionId, sessions]);
const addMessage = useCallback(
async (content: string, role: ChatMessage["role"]) => {
if (!activeSessionId) return;
const message: ChatMessage = {
id: uuid(),
role,
content,
timestamp: new Date().toISOString(),
};
setSessions((prev) =>
prev.map((s) => {
if (s.id !== activeSessionId) return s;
return { ...s, messages: [...s.messages, message] };
}),
);
// Persist
const current = sessions.find((s) => s.id === activeSessionId);
if (current) {
const updated = {
...current,
messages: [...current.messages, message],
};
await storage.set(`${SESSION_PREFIX}${activeSessionId}`, updated);
}
return message;
},
[storage, activeSessionId, sessions],
);
const deleteSession = useCallback(async (id: string) => {
await storage.delete(`${SESSION_PREFIX}${id}`);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (activeSessionId === id) {
setActiveSessionId(null);
}
}, [storage, activeSessionId]);
const deleteSession = useCallback(
async (id: string) => {
await storage.delete(`${SESSION_PREFIX}${id}`);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (activeSessionId === id) {
setActiveSessionId(null);
}
},
[storage, activeSessionId],
);
const selectSession = useCallback((id: string) => {
setActiveSessionId(id);
@@ -1,31 +1,37 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { SignatureAsset, SignatureAssetType, AssetVersion } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type {
SignatureAsset,
SignatureAssetType,
AssetVersion,
} from "../types";
const PREFIX = 'sig:';
const PREFIX = "sig:";
export interface SignatureFilters {
search: string;
type: SignatureAssetType | 'all';
type: SignatureAssetType | "all";
}
export function useSignatures() {
const storage = useStorage('digital-signatures');
const storage = useStorage("digital-signatures");
const [assets, setAssets] = useState<SignatureAsset[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<SignatureFilters>({ search: '', type: 'all' });
const [filters, setFilters] = useState<SignatureFilters>({
search: "",
type: "all",
});
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: SignatureAsset[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<SignatureAsset>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as SignatureAsset);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -34,53 +40,95 @@ export function useSignatures() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${asset.id}`, asset);
await refresh();
return asset;
}, [storage, refresh]);
const addAsset = useCallback(
async (data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString();
const asset: SignatureAsset = {
...data,
id: uuid(),
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${asset.id}`, asset);
await refresh();
return asset;
},
[storage, refresh],
);
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
const existing = assets.find((a) => a.id === id);
if (!existing) return;
const updated: SignatureAsset = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, assets]);
const updateAsset = useCallback(
async (id: string, updates: Partial<SignatureAsset>) => {
const existing = assets.find((a) => a.id === id);
if (!existing) return;
const updated: SignatureAsset = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
},
[storage, refresh, assets],
);
const addVersion = useCallback(async (assetId: string, imageUrl: string, notes: string) => {
const existing = assets.find((a) => a.id === assetId);
if (!existing) return;
const version: AssetVersion = { id: uuid(), imageUrl, notes, createdAt: new Date().toISOString() };
const updatedVersions = [...(existing.versions ?? []), version];
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
}, [assets, updateAsset]);
const addVersion = useCallback(
async (assetId: string, imageUrl: string, notes: string) => {
const existing = assets.find((a) => a.id === assetId);
if (!existing) return;
const version: AssetVersion = {
id: uuid(),
imageUrl,
notes,
createdAt: new Date().toISOString(),
};
const updatedVersions = [...(existing.versions ?? []), version];
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
},
[assets, updateAsset],
);
const removeAsset = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const removeAsset = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
const updateFilter = useCallback(<K extends keyof SignatureFilters>(key: K, value: SignatureFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const updateFilter = useCallback(
<K extends keyof SignatureFilters>(key: K, value: SignatureFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[],
);
const filteredAssets = assets.filter((a) => {
if (filters.type !== 'all' && a.type !== filters.type) return false;
if (filters.type !== "all" && a.type !== filters.type) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return a.label.toLowerCase().includes(q) || a.owner.toLowerCase().includes(q);
return (
a.label.toLowerCase().includes(q) || a.owner.toLowerCase().includes(q)
);
}
return true;
});
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset, refresh };
return {
assets: filteredAssets,
allAssets: assets,
loading,
filters,
updateFilter,
addAsset,
updateAsset,
addVersion,
removeAsset,
refresh,
};
}
@@ -1,23 +1,22 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { SignatureConfig, SavedSignature } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { SignatureConfig, SavedSignature } from "../types";
export function useSavedSignatures() {
const storage = useStorage('email-signature');
const storage = useStorage("email-signature");
const [saved, setSaved] = useState<SavedSignature[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const items: SavedSignature[] = [];
for (const key of keys) {
if (key.startsWith('sig:')) {
const item = await storage.get<SavedSignature>(key);
if (item) items.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith("sig:") && value) {
items.push(value as SavedSignature);
}
}
items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -26,26 +25,34 @@ export function useSavedSignatures() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const save = useCallback(async (label: string, config: SignatureConfig) => {
const now = new Date().toISOString();
const entry: SavedSignature = {
id: uuid(),
label,
config,
createdAt: now,
updatedAt: now,
};
await storage.set(`sig:${entry.id}`, entry);
await refresh();
return entry;
}, [storage, refresh]);
const save = useCallback(
async (label: string, config: SignatureConfig) => {
const now = new Date().toISOString();
const entry: SavedSignature = {
id: uuid(),
label,
config,
createdAt: now,
updatedAt: now,
};
await storage.set(`sig:${entry.id}`, entry);
await refresh();
return entry;
},
[storage, refresh],
);
const remove = useCallback(async (id: string) => {
await storage.delete(`sig:${id}`);
await refresh();
}, [storage, refresh]);
const remove = useCallback(
async (id: string) => {
await storage.delete(`sig:${id}`);
await refresh();
},
[storage, refresh],
);
return { saved, loading, save, remove, refresh };
}
@@ -14,12 +14,11 @@ export function useReservations() {
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: DeskReservation[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<DeskReservation>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as DeskReservation);
}
}
results.sort((a, b) => a.date.localeCompare(b.date));
@@ -68,6 +68,9 @@ export function ServerRack({ items }: ServerRackProps) {
return result;
}, [items]);
// Reverse so U1 is at the bottom (standard rack numbering)
const displaySlots = useMemo(() => [...slots].reverse(), [slots]);
const rackItems = items.filter((i) => i.rackPosition);
if (rackItems.length === 0) {
@@ -97,7 +100,7 @@ export function ServerRack({ items }: ServerRackProps) {
{/* Units */}
<div className="p-1 space-y-px">
{slots.map((slot) => {
{displaySlots.map((slot) => {
if (!slot.isStart && slot.item) return null;
const heightPx = slot.height * 24;
@@ -27,12 +27,11 @@ export function useInventory() {
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: InventoryItem[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<InventoryItem>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as InventoryItem);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+81 -39
View File
@@ -1,31 +1,33 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { VaultEntry, VaultEntryCategory } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { VaultEntry, VaultEntryCategory } from "../types";
const PREFIX = 'vault:';
const PREFIX = "vault:";
export interface VaultFilters {
search: string;
category: VaultEntryCategory | 'all';
category: VaultEntryCategory | "all";
}
export function useVault() {
const storage = useStorage('password-vault');
const storage = useStorage("password-vault");
const [entries, setEntries] = useState<VaultEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<VaultFilters>({ search: '', category: 'all' });
const [filters, setFilters] = useState<VaultFilters>({
search: "",
category: "all",
});
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: VaultEntry[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<VaultEntry>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as VaultEntry);
}
}
results.sort((a, b) => a.label.localeCompare(b.label));
@@ -34,41 +36,81 @@ export function useVault() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const addEntry = useCallback(async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const entry: VaultEntry = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${entry.id}`, entry);
await refresh();
return entry;
}, [storage, refresh]);
const addEntry = useCallback(
async (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString();
const entry: VaultEntry = {
...data,
id: uuid(),
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${entry.id}`, entry);
await refresh();
return entry;
},
[storage, refresh],
);
const updateEntry = useCallback(async (id: string, updates: Partial<VaultEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt, updatedAt: new Date().toISOString() };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, entries]);
const updateEntry = useCallback(
async (id: string, updates: Partial<VaultEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
},
[storage, refresh, entries],
);
const removeEntry = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const removeEntry = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
const updateFilter = useCallback(<K extends keyof VaultFilters>(key: K, value: VaultFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const updateFilter = useCallback(
<K extends keyof VaultFilters>(key: K, value: VaultFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[],
);
const filteredEntries = entries.filter((e) => {
if (filters.category !== 'all' && e.category !== filters.category) return false;
if (filters.category !== "all" && e.category !== filters.category)
return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return e.label.toLowerCase().includes(q) || e.username.toLowerCase().includes(q) || e.url.toLowerCase().includes(q);
return (
e.label.toLowerCase().includes(q) ||
e.username.toLowerCase().includes(q) ||
e.url.toLowerCase().includes(q)
);
}
return true;
});
return { entries: filteredEntries, allEntries: entries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry, refresh };
return {
entries: filteredEntries,
allEntries: entries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
refresh,
};
}
@@ -1,39 +1,42 @@
'use client';
"use client";
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { PromptTemplate, PromptHistoryEntry, OutputMode } from '../types';
import { BUILTIN_TEMPLATES } from '../services/builtin-templates';
import { composePrompt } from '../services/prompt-composer';
import { useState, useCallback, useMemo, useEffect } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { PromptTemplate, PromptHistoryEntry, OutputMode } from "../types";
import { BUILTIN_TEMPLATES } from "../services/builtin-templates";
import { composePrompt } from "../services/prompt-composer";
const HISTORY_PREFIX = 'history:';
const TEMPLATE_PREFIX = 'template:';
const HISTORY_PREFIX = "history:";
const TEMPLATE_PREFIX = "template:";
export function usePromptGenerator() {
const storage = useStorage('prompt-generator');
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
const storage = useStorage("prompt-generator");
const [selectedTemplate, setSelectedTemplate] =
useState<PromptTemplate | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [customTemplates, setCustomTemplates] = useState<PromptTemplate[]>([]);
const [history, setHistory] = useState<PromptHistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const allTemplates = useMemo(() => [...BUILTIN_TEMPLATES, ...customTemplates], [customTemplates]);
const allTemplates = useMemo(
() => [...BUILTIN_TEMPLATES, ...customTemplates],
[customTemplates],
);
// Load custom templates and history
useEffect(() => {
async function load() {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const templates: PromptTemplate[] = [];
const entries: PromptHistoryEntry[] = [];
for (const key of keys) {
for (const [key, value] of Object.entries(all)) {
if (!value) continue;
if (key.startsWith(TEMPLATE_PREFIX)) {
const t = await storage.get<PromptTemplate>(key);
if (t) templates.push(t);
templates.push(value as PromptTemplate);
} else if (key.startsWith(HISTORY_PREFIX)) {
const h = await storage.get<PromptHistoryEntry>(key);
if (h) entries.push(h);
entries.push(value as PromptHistoryEntry);
}
}
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -59,7 +62,7 @@ export function usePromptGenerator() {
}, []);
const composedPrompt = useMemo(() => {
if (!selectedTemplate) return '';
if (!selectedTemplate) return "";
return composePrompt(selectedTemplate, values);
}, [selectedTemplate, values]);
@@ -74,7 +77,10 @@ export function usePromptGenerator() {
composedPrompt,
outputMode: selectedTemplate.outputMode,
providerProfile: selectedTemplate.providerProfile ?? null,
safetyBlocks: selectedTemplate.safetyBlocks?.filter((s) => s.enabled).map((s) => s.id) ?? [],
safetyBlocks:
selectedTemplate.safetyBlocks
?.filter((s) => s.enabled)
.map((s) => s.id) ?? [],
tags: selectedTemplate.tags,
isFavorite: false,
createdAt: new Date().toISOString(),
@@ -84,10 +90,13 @@ export function usePromptGenerator() {
return entry;
}, [storage, selectedTemplate, values, composedPrompt]);
const deleteHistoryEntry = useCallback(async (id: string) => {
await storage.delete(`${HISTORY_PREFIX}${id}`);
setHistory((prev) => prev.filter((h) => h.id !== id));
}, [storage]);
const deleteHistoryEntry = useCallback(
async (id: string) => {
await storage.delete(`${HISTORY_PREFIX}${id}`);
setHistory((prev) => prev.filter((h) => h.id !== id));
},
[storage],
);
const clearSelection = useCallback(() => {
setSelectedTemplate(null);
+27 -18
View File
@@ -59,7 +59,8 @@ export function useRegistry() {
async (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => {
// Fetch fresh entries to prevent duplicate numbers from stale state
// Fetch fresh entries to prevent duplicate numbers from stale state.
// This single call replaces what was previously list() + N×get().
const freshEntries = await getAllEntries(storage);
const now = new Date().toISOString();
const number = generateRegistryNumber(
@@ -76,10 +77,11 @@ export function useRegistry() {
updatedAt: now,
};
await saveEntry(storage, entry);
await refresh();
// Update local state directly to avoid a second full fetch
setEntries((prev) => [entry, ...prev]);
return entry;
},
[storage, refresh],
[storage],
);
const updateEntry = useCallback(
@@ -108,29 +110,36 @@ export function useRegistry() {
[storage, refresh],
);
/** Close an entry and optionally its linked entries */
/** Close an entry and optionally its linked entries.
* Batches all saves, then does a single refresh at the end.
*/
const closeEntry = useCallback(
async (id: string, closeLinked: boolean) => {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
await updateEntry(id, { status: "inchis" });
// Save main entry as closed
const now = new Date().toISOString();
const closedMain: RegistryEntry = {
...entry,
status: "inchis",
updatedAt: now,
};
await saveEntry(storage, closedMain);
// Close linked entries in parallel
const linked = entry.linkedEntryIds ?? [];
if (closeLinked && linked.length > 0) {
for (const linkedId of linked) {
const linked = entries.find((e) => e.id === linkedId);
if (linked && linked.status !== "inchis") {
const updatedLinked: RegistryEntry = {
...linked,
status: "inchis",
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updatedLinked);
}
}
await refresh();
const saves = linked
.map((linkedId) => entries.find((e) => e.id === linkedId))
.filter((e): e is RegistryEntry => !!e && e.status !== "inchis")
.map((e) =>
saveEntry(storage, { ...e, status: "inchis", updatedAt: now }),
);
await Promise.all(saves);
}
// Single refresh at the end
await refresh();
},
[entries, updateEntry, storage, refresh],
[entries, storage, refresh],
);
const updateFilter = useCallback(
@@ -8,17 +8,22 @@ export interface RegistryStorage {
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
list(): Promise<string[]>;
exportAll(): Promise<Record<string, unknown>>;
}
/**
* Load all registry entries in a SINGLE request.
* Uses exportAll() which fetches namespace data in one HTTP call,
* avoiding the N+1 pattern (list keys → get each one individually).
*/
export async function getAllEntries(
storage: RegistryStorage,
): Promise<RegistryEntry[]> {
const keys = await storage.list();
const all = await storage.exportAll();
const entries: RegistryEntry[] = [];
for (const key of keys) {
if (key.startsWith(STORAGE_PREFIX)) {
const entry = await storage.get<RegistryEntry>(key);
if (entry) entries.push(entry);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(STORAGE_PREFIX) && value) {
entries.push(value as RegistryEntry);
}
}
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+103 -60
View File
@@ -1,32 +1,35 @@
'use client';
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { WordTemplate, TemplateCategory } from '../types';
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { WordTemplate, TemplateCategory } from "../types";
const PREFIX = 'tpl:';
const PREFIX = "tpl:";
export interface TemplateFilters {
search: string;
category: TemplateCategory | 'all';
category: TemplateCategory | "all";
company: string;
}
export function useTemplates() {
const storage = useStorage('word-templates');
const storage = useStorage("word-templates");
const [templates, setTemplates] = useState<WordTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all', company: 'all' });
const [filters, setFilters] = useState<TemplateFilters>({
search: "",
category: "all",
company: "all",
});
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const all = await storage.exportAll();
const results: WordTemplate[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<WordTemplate>(key);
if (item) results.push(item);
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as WordTemplate);
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
@@ -35,63 +38,103 @@ export function useTemplates() {
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${template.id}`, template);
await refresh();
return template;
}, [storage, refresh]);
const addTemplate = useCallback(
async (data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString();
const template: WordTemplate = {
...data,
id: uuid(),
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${template.id}`, template);
await refresh();
return template;
},
[storage, refresh],
);
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const updated: WordTemplate = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, templates]);
const updateTemplate = useCallback(
async (id: string, updates: Partial<WordTemplate>) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const updated: WordTemplate = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
},
[storage, refresh, templates],
);
const cloneTemplate = useCallback(async (id: string) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const now = new Date().toISOString();
const cloned: WordTemplate = {
...existing,
id: uuid(),
name: `${existing.name} (copie)`,
clonedFrom: existing.id,
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${cloned.id}`, cloned);
await refresh();
return cloned;
}, [storage, refresh, templates]);
const cloneTemplate = useCallback(
async (id: string) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const now = new Date().toISOString();
const cloned: WordTemplate = {
...existing,
id: uuid(),
name: `${existing.name} (copie)`,
clonedFrom: existing.id,
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${cloned.id}`, cloned);
await refresh();
return cloned;
},
[storage, refresh, templates],
);
const removeTemplate = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const removeTemplate = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
const updateFilter = useCallback(<K extends keyof TemplateFilters>(key: K, value: TemplateFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const updateFilter = useCallback(
<K extends keyof TemplateFilters>(key: K, value: TemplateFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[],
);
const filteredTemplates = templates.filter((t) => {
if (filters.category !== 'all' && t.category !== filters.category) return false;
if (filters.company !== 'all' && t.company !== filters.company) return false;
if (filters.category !== "all" && t.category !== filters.category)
return false;
if (filters.company !== "all" && t.company !== filters.company)
return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
return (
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q)
);
}
return true;
});
return { templates: filteredTemplates, allTemplates: templates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate, refresh };
return {
templates: filteredTemplates,
allTemplates: templates,
loading,
filters,
updateFilter,
addTemplate,
updateTemplate,
cloneTemplate,
removeTemplate,
refresh,
};
}