Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, useCallback } from 'react';
|
||||
import type { AuthContextValue, User, Role } from './types';
|
||||
|
||||
const ROLE_HIERARCHY: Record<Role, number> = {
|
||||
admin: 4,
|
||||
manager: 3,
|
||||
user: 2,
|
||||
viewer: 1,
|
||||
guest: 0,
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
// Stub user for development (no auth required)
|
||||
const STUB_USER: User = {
|
||||
id: 'dev-user',
|
||||
name: 'Utilizator Intern',
|
||||
email: 'dev@architools.local',
|
||||
role: 'admin',
|
||||
company: 'beletage',
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
// In the current phase, always return the stub user
|
||||
// Future: replace with Authentik OIDC token resolution
|
||||
const user = STUB_USER;
|
||||
|
||||
const hasRole = useCallback(
|
||||
(requiredRole: Role) => {
|
||||
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[requiredRole];
|
||||
},
|
||||
[user.role]
|
||||
);
|
||||
|
||||
const canAccessModule = useCallback(
|
||||
(_moduleId: string) => {
|
||||
// Future: check module-level permissions
|
||||
return true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const value: AuthContextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
role: user.role,
|
||||
isAuthenticated: true,
|
||||
hasRole,
|
||||
canAccessModule,
|
||||
}),
|
||||
[user, hasRole, canAccessModule]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { User, Role, CompanyId, AuthContextValue } from './types';
|
||||
export { AuthProvider, useAuth } from './auth-provider';
|
||||
@@ -0,0 +1,19 @@
|
||||
export type Role = 'admin' | 'manager' | 'user' | 'viewer' | 'guest';
|
||||
|
||||
export type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren' | 'group';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
company: CompanyId;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: User | null;
|
||||
role: Role;
|
||||
isAuthenticated: boolean;
|
||||
hasRole: (role: Role) => boolean;
|
||||
canAccessModule: (moduleId: string) => boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlag } from './use-feature-flag';
|
||||
|
||||
interface FeatureGateProps {
|
||||
flag: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
|
||||
const enabled = useFeatureFlag(flag);
|
||||
return enabled ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||
import type { FeatureFlag } from './types';
|
||||
import { resolveAllFlags, loadRuntimeOverrides, saveRuntimeOverride, clearRuntimeOverrides } from './flag-service';
|
||||
|
||||
interface FeatureFlagContextValue {
|
||||
flags: Record<string, boolean>;
|
||||
isEnabled: (key: string) => boolean;
|
||||
setOverride: (key: string, value: boolean) => void;
|
||||
clearOverrides: () => void;
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
interface FeatureFlagProviderProps {
|
||||
flagDefinitions: FeatureFlag[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureFlagProvider({ flagDefinitions, children }: FeatureFlagProviderProps) {
|
||||
const [runtimeOverrides, setRuntimeOverrides] = useState<Record<string, boolean>>(() => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
return loadRuntimeOverrides();
|
||||
});
|
||||
|
||||
const envOverrides = useMemo(() => {
|
||||
const env: Record<string, string> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
// Collect NEXT_PUBLIC_FLAG_* env vars injected at build time
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('NEXT_PUBLIC_FLAG_') && value) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}, []);
|
||||
|
||||
const flags = useMemo(
|
||||
() => resolveAllFlags(flagDefinitions, envOverrides, runtimeOverrides),
|
||||
[flagDefinitions, envOverrides, runtimeOverrides]
|
||||
);
|
||||
|
||||
const isEnabled = useCallback((key: string) => flags[key] ?? false, [flags]);
|
||||
|
||||
const setOverride = useCallback(
|
||||
(key: string, value: boolean) => {
|
||||
saveRuntimeOverride(key, value);
|
||||
setRuntimeOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClearOverrides = useCallback(() => {
|
||||
clearRuntimeOverrides();
|
||||
setRuntimeOverrides({});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ flags, isEnabled, setOverride, clearOverrides: handleClearOverrides }),
|
||||
[flags, isEnabled, setOverride, handleClearOverrides]
|
||||
);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={value}>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFeatureFlags(): FeatureFlagContextValue {
|
||||
const ctx = useContext(FeatureFlagContext);
|
||||
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FeatureFlag } from './types';
|
||||
|
||||
const RUNTIME_OVERRIDE_KEY = 'architools:flag-overrides';
|
||||
|
||||
export function resolveFlag(
|
||||
flag: FeatureFlag,
|
||||
envOverrides: Record<string, string>,
|
||||
runtimeOverrides: Record<string, boolean>
|
||||
): boolean {
|
||||
// 1. Check environment variable override
|
||||
const envKey = `NEXT_PUBLIC_FLAG_${flag.key.toUpperCase().replace(/\./g, '_')}`;
|
||||
const envVal = envOverrides[envKey];
|
||||
if (envVal !== undefined) {
|
||||
return envVal === 'true';
|
||||
}
|
||||
|
||||
// 2. Check runtime override
|
||||
if (flag.overridable && runtimeOverrides[flag.key] !== undefined) {
|
||||
return runtimeOverrides[flag.key]!;
|
||||
}
|
||||
|
||||
// 3. Fall back to default
|
||||
return flag.enabled;
|
||||
}
|
||||
|
||||
export function resolveAllFlags(
|
||||
flags: FeatureFlag[],
|
||||
envOverrides: Record<string, string>,
|
||||
runtimeOverrides: Record<string, boolean>
|
||||
): Record<string, boolean> {
|
||||
const resolved: Record<string, boolean> = {};
|
||||
|
||||
// First pass: resolve without dependency checks
|
||||
for (const flag of flags) {
|
||||
resolved[flag.key] = resolveFlag(flag, envOverrides, runtimeOverrides);
|
||||
}
|
||||
|
||||
// Second pass: enforce dependencies
|
||||
for (const flag of flags) {
|
||||
if (resolved[flag.key] && flag.dependencies) {
|
||||
for (const dep of flag.dependencies) {
|
||||
if (!resolved[dep]) {
|
||||
resolved[flag.key] = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function loadRuntimeOverrides(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(RUNTIME_OVERRIDE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function saveRuntimeOverride(key: string, value: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const overrides = loadRuntimeOverrides();
|
||||
overrides[key] = value;
|
||||
window.localStorage.setItem(RUNTIME_OVERRIDE_KEY, JSON.stringify(overrides));
|
||||
}
|
||||
|
||||
export function clearRuntimeOverrides(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(RUNTIME_OVERRIDE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { FeatureFlag, FlagCategory } from './types';
|
||||
export { FeatureFlagProvider, useFeatureFlags } from './flag-provider';
|
||||
export { useFeatureFlag } from './use-feature-flag';
|
||||
export { FeatureGate } from './feature-gate';
|
||||
@@ -0,0 +1,11 @@
|
||||
export type FlagCategory = 'module' | 'experimental' | 'system';
|
||||
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
category: FlagCategory;
|
||||
dependencies?: string[];
|
||||
overridable: boolean;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlags } from './flag-provider';
|
||||
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { isEnabled } = useFeatureFlags();
|
||||
return isEnabled(key);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useCallback } from 'react';
|
||||
import { ro } from './locales/ro';
|
||||
import type { Labels } from './types';
|
||||
|
||||
interface I18nContextValue {
|
||||
labels: Labels;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
interface I18nProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: I18nProviderProps) {
|
||||
const labels = ro;
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
const [namespace, ...rest] = key.split('.');
|
||||
const labelKey = rest.join('.');
|
||||
if (!namespace || !labelKey) return key;
|
||||
return labels[namespace]?.[labelKey] ?? key;
|
||||
},
|
||||
[labels]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ labels, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useLabel(key: string): string {
|
||||
const { t } = useI18n();
|
||||
return t(key);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Labels, LabelNamespace } from './types';
|
||||
export { I18nProvider, useI18n, useLabel } from './i18n-provider';
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Labels } from '../types';
|
||||
|
||||
export const ro: Labels = {
|
||||
common: {
|
||||
save: 'Salvează',
|
||||
cancel: 'Anulează',
|
||||
delete: 'Șterge',
|
||||
edit: 'Editează',
|
||||
create: 'Creează',
|
||||
search: 'Caută',
|
||||
filter: 'Filtrează',
|
||||
export: 'Exportă',
|
||||
import: 'Importă',
|
||||
copy: 'Copiază',
|
||||
close: 'Închide',
|
||||
confirm: 'Confirmă',
|
||||
back: 'Înapoi',
|
||||
next: 'Următorul',
|
||||
loading: 'Se încarcă...',
|
||||
noResults: 'Niciun rezultat',
|
||||
error: 'Eroare',
|
||||
success: 'Succes',
|
||||
actions: 'Acțiuni',
|
||||
settings: 'Setări',
|
||||
all: 'Toate',
|
||||
yes: 'Da',
|
||||
no: 'Nu',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Panou principal',
|
||||
operations: 'Operațiuni',
|
||||
generators: 'Generatoare',
|
||||
management: 'Management',
|
||||
tools: 'Instrumente',
|
||||
ai: 'AI & Automatizări',
|
||||
externalTools: 'Instrumente externe',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Panou principal',
|
||||
welcome: 'Bine ai venit în ArchiTools',
|
||||
subtitle: 'Platforma internă de instrumente pentru birou',
|
||||
quickActions: 'Acțiuni rapide',
|
||||
recentActivity: 'Activitate recentă',
|
||||
modules: 'Module',
|
||||
infrastructure: 'Infrastructură',
|
||||
},
|
||||
registratura: {
|
||||
title: 'Registratură',
|
||||
description: 'Registru de corespondență multi-firmă',
|
||||
newEntry: 'Înregistrare nouă',
|
||||
entries: 'Înregistrări',
|
||||
incoming: 'Intrare',
|
||||
outgoing: 'Ieșire',
|
||||
internal: 'Intern',
|
||||
},
|
||||
'email-signature': {
|
||||
title: 'Generator Semnătură Email',
|
||||
description: 'Configurator semnătură email pentru companii',
|
||||
preview: 'Previzualizare',
|
||||
downloadHtml: 'Descarcă HTML',
|
||||
},
|
||||
'word-xml': {
|
||||
title: 'Generator XML Word',
|
||||
description: 'Generator Custom XML Parts pentru Word',
|
||||
generate: 'Generează XML',
|
||||
downloadXml: 'Descarcă XML',
|
||||
downloadZip: 'Descarcă ZIP',
|
||||
},
|
||||
'prompt-generator': {
|
||||
title: 'Generator Prompturi',
|
||||
description: 'Constructor de prompturi structurate pentru AI',
|
||||
templates: 'Șabloane',
|
||||
compose: 'Compune',
|
||||
history: 'Istoric',
|
||||
preview: 'Previzualizare',
|
||||
},
|
||||
'digital-signatures': {
|
||||
title: 'Semnături și Ștampile',
|
||||
description: 'Bibliotecă semnături digitale și ștampile scanate',
|
||||
},
|
||||
'password-vault': {
|
||||
title: 'Seif Parole',
|
||||
description: 'Depozit intern de credențiale partajate',
|
||||
},
|
||||
'it-inventory': {
|
||||
title: 'Inventar IT',
|
||||
description: 'Evidența echipamentelor și dispozitivelor',
|
||||
},
|
||||
'address-book': {
|
||||
title: 'Contacte',
|
||||
description: 'Clienți, furnizori, instituții',
|
||||
},
|
||||
'word-templates': {
|
||||
title: 'Șabloane Word',
|
||||
description: 'Bibliotecă contracte, oferte, rapoarte',
|
||||
},
|
||||
'tag-manager': {
|
||||
title: 'Manager Etichete',
|
||||
description: 'Administrare etichete proiecte și categorii',
|
||||
},
|
||||
'mini-utilities': {
|
||||
title: 'Utilitare',
|
||||
description: 'Calculatoare tehnice și instrumente text',
|
||||
},
|
||||
'ai-chat': {
|
||||
title: 'Chat AI',
|
||||
description: 'Interfață asistent AI',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type LabelNamespace = 'common' | 'nav' | 'dashboard' | string;
|
||||
|
||||
export type Labels = Record<string, Record<string, string>>;
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { ModuleConfig, ModuleCategory, Visibility } from './types';
|
||||
export { MODULE_CATEGORY_LABELS } from './types';
|
||||
export {
|
||||
registerModules,
|
||||
getAllModules,
|
||||
getModuleById,
|
||||
getModuleByRoute,
|
||||
getModulesByCategory,
|
||||
} from './registry';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ModuleConfig, ModuleCategory } from './types';
|
||||
|
||||
let registeredModules: ModuleConfig[] = [];
|
||||
let moduleMap: Map<string, ModuleConfig> = new Map();
|
||||
|
||||
export function registerModules(configs: ModuleConfig[]): void {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
validateRegistry(configs);
|
||||
}
|
||||
registeredModules = [...configs].sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.navOrder - b.navOrder;
|
||||
});
|
||||
moduleMap = new Map(registeredModules.map((c) => [c.id, c]));
|
||||
}
|
||||
|
||||
export function getAllModules(): ModuleConfig[] {
|
||||
return registeredModules;
|
||||
}
|
||||
|
||||
export function getModuleById(id: string): ModuleConfig | undefined {
|
||||
return moduleMap.get(id);
|
||||
}
|
||||
|
||||
export function getModuleByRoute(route: string): ModuleConfig | undefined {
|
||||
return registeredModules.find((m) => m.route === route);
|
||||
}
|
||||
|
||||
export function getModulesByCategory(category: ModuleCategory): ModuleConfig[] {
|
||||
return registeredModules.filter((m) => m.category === category);
|
||||
}
|
||||
|
||||
function validateRegistry(configs: ModuleConfig[]): void {
|
||||
const ids = configs.map((m) => m.id);
|
||||
const duplicateIds = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (duplicateIds.length > 0) {
|
||||
throw new Error(`Duplicate module IDs: ${duplicateIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const routes = configs.map((m) => m.route);
|
||||
const duplicateRoutes = routes.filter((r, i) => routes.indexOf(r) !== i);
|
||||
if (duplicateRoutes.length > 0) {
|
||||
throw new Error(`Duplicate module routes: ${duplicateRoutes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type ModuleCategory =
|
||||
| 'operations'
|
||||
| 'generators'
|
||||
| 'management'
|
||||
| 'tools'
|
||||
| 'ai';
|
||||
|
||||
export type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
category: ModuleCategory;
|
||||
featureFlag: string;
|
||||
visibility: Visibility;
|
||||
version: string;
|
||||
dependencies?: string[];
|
||||
storageNamespace: string;
|
||||
navOrder: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const MODULE_CATEGORY_LABELS: Record<ModuleCategory, string> = {
|
||||
operations: 'Operațiuni',
|
||||
generators: 'Generatoare',
|
||||
management: 'Management',
|
||||
tools: 'Instrumente',
|
||||
ai: 'AI & Automatizări',
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { StorageService } from '../types';
|
||||
|
||||
function nsKey(namespace: string, key: string): string {
|
||||
return `architools:${namespace}:${key}`;
|
||||
}
|
||||
|
||||
function nsPrefix(namespace: string): string {
|
||||
return `architools:${namespace}:`;
|
||||
}
|
||||
|
||||
export class LocalStorageAdapter implements StorageService {
|
||||
async get<T>(namespace: string, key: string): Promise<T | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(nsKey(namespace, key));
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(namespace: string, key: string, value: T): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(nsKey(namespace, key), JSON.stringify(value));
|
||||
}
|
||||
|
||||
async delete(namespace: string, key: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(nsKey(namespace, key));
|
||||
}
|
||||
|
||||
async list(namespace: string): Promise<string[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const prefix = nsPrefix(namespace);
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && k.startsWith(prefix)) {
|
||||
keys.push(k.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
async query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]> {
|
||||
const keys = await this.list(namespace);
|
||||
const results: T[] = [];
|
||||
for (const key of keys) {
|
||||
const item = await this.get<T>(namespace, key);
|
||||
if (item !== null && predicate(item)) {
|
||||
results.push(item);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async clear(namespace: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
const keys = await this.list(namespace);
|
||||
for (const key of keys) {
|
||||
window.localStorage.removeItem(nsKey(namespace, key));
|
||||
}
|
||||
}
|
||||
|
||||
async export(namespace: string): Promise<Record<string, unknown>> {
|
||||
const keys = await this.list(namespace);
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
data[key] = await this.get(namespace, key);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async import(namespace: string, data: Record<string, unknown>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
await this.set(namespace, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { StorageService } from './types';
|
||||
export { StorageProvider, useStorageService } from './storage-provider';
|
||||
export { useStorage } from './use-storage';
|
||||
export type { NamespacedStorage } from './use-storage';
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import type { StorageService } from './types';
|
||||
import { LocalStorageAdapter } from './adapters/local-storage';
|
||||
|
||||
const StorageContext = createContext<StorageService | null>(null);
|
||||
|
||||
function createAdapter(): StorageService {
|
||||
// Future: select adapter based on environment variable
|
||||
// const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER;
|
||||
return new LocalStorageAdapter();
|
||||
}
|
||||
|
||||
interface StorageProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StorageProvider({ children }: StorageProviderProps) {
|
||||
const service = useMemo(() => createAdapter(), []);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={service}>
|
||||
{children}
|
||||
</StorageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStorageService(): StorageService {
|
||||
const ctx = useContext(StorageContext);
|
||||
if (!ctx) throw new Error('useStorageService must be used within StorageProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface StorageService {
|
||||
get<T>(namespace: string, key: string): Promise<T | null>;
|
||||
set<T>(namespace: string, key: string, value: T): Promise<void>;
|
||||
delete(namespace: string, key: string): Promise<void>;
|
||||
list(namespace: string): Promise<string[]>;
|
||||
query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]>;
|
||||
clear(namespace: string): Promise<void>;
|
||||
export(namespace: string): Promise<Record<string, unknown>>;
|
||||
import(namespace: string, data: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useStorageService } from './storage-provider';
|
||||
|
||||
export interface NamespacedStorage {
|
||||
get: <T>(key: string) => Promise<T | null>;
|
||||
set: <T>(key: string, value: T) => Promise<void>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
list: () => Promise<string[]>;
|
||||
query: <T>(predicate: (item: T) => boolean) => Promise<T[]>;
|
||||
clear: () => Promise<void>;
|
||||
exportAll: () => Promise<Record<string, unknown>>;
|
||||
importAll: (data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useStorage(namespace: string): NamespacedStorage {
|
||||
const service = useStorageService();
|
||||
|
||||
const get = useCallback(
|
||||
<T,>(key: string) => service.get<T>(namespace, key),
|
||||
[service, namespace]
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
<T,>(key: string, value: T) => service.set<T>(namespace, key, value),
|
||||
[service, namespace]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
(key: string) => service.delete(namespace, key),
|
||||
[service, namespace]
|
||||
);
|
||||
|
||||
const list = useCallback(() => service.list(namespace), [service, namespace]);
|
||||
|
||||
const query = useCallback(
|
||||
<T,>(predicate: (item: T) => boolean) => service.query<T>(namespace, predicate),
|
||||
[service, namespace]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => service.clear(namespace), [service, namespace]);
|
||||
|
||||
const exportAll = useCallback(() => service.export(namespace), [service, namespace]);
|
||||
|
||||
const importAll = useCallback(
|
||||
(data: Record<string, unknown>) => service.import(namespace, data),
|
||||
[service, namespace]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({ get, set, delete: del, list, query, clear, exportAll, importAll }),
|
||||
[get, set, del, list, query, clear, exportAll, importAll]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TagService } from './tag-service';
|
||||
export { useTags } from './use-tags';
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { StorageService } from '@/core/storage/types';
|
||||
import type { Tag, TagCategory, TagScope } from './types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const NAMESPACE = 'tags';
|
||||
|
||||
export class TagService {
|
||||
constructor(private storage: StorageService) {}
|
||||
|
||||
async getAllTags(): Promise<Tag[]> {
|
||||
const keys = await this.storage.list(NAMESPACE);
|
||||
const tags: Tag[] = [];
|
||||
for (const key of keys) {
|
||||
const tag = await this.storage.get<Tag>(NAMESPACE, key);
|
||||
if (tag) tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
async getTagsByCategory(category: TagCategory): Promise<Tag[]> {
|
||||
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;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
|
||||
const tag: Tag = {
|
||||
...data,
|
||||
id: uuid(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.storage.set(NAMESPACE, tag.id, tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
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 = { ...existing, ...updates };
|
||||
await this.storage.set(NAMESPACE, id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteTag(id: string): Promise<void> {
|
||||
await this.storage.delete(NAMESPACE, id);
|
||||
}
|
||||
|
||||
async searchTags(query: string): Promise<Tag[]> {
|
||||
const lower = query.toLowerCase();
|
||||
return this.storage.query<Tag>(NAMESPACE, (tag) =>
|
||||
tag.label.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type TagCategory =
|
||||
| 'project'
|
||||
| 'phase'
|
||||
| 'activity'
|
||||
| 'document-type'
|
||||
| 'company'
|
||||
| 'priority'
|
||||
| 'status'
|
||||
| 'custom';
|
||||
|
||||
export type TagScope = 'global' | 'module' | 'company';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
category: TagCategory;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
scope: TagScope;
|
||||
moduleId?: string;
|
||||
companyId?: CompanyId;
|
||||
parentId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useStorageService } from '@/core/storage';
|
||||
import { TagService } from './tag-service';
|
||||
import type { Tag, TagCategory } from './types';
|
||||
|
||||
export function useTags(category?: TagCategory) {
|
||||
const storage = useStorageService();
|
||||
const service = useMemo(() => new TagService(storage), [storage]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = category
|
||||
? await service.getTagsByCategory(category)
|
||||
: await service.getAllTags();
|
||||
setTags(result);
|
||||
setLoading(false);
|
||||
}, [service, category]);
|
||||
|
||||
// Load initial data from external storage
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const createTag = useCallback(
|
||||
async (data: Omit<Tag, 'id' | 'createdAt'>) => {
|
||||
const tag = await service.createTag(data);
|
||||
await refresh();
|
||||
return tag;
|
||||
},
|
||||
[service, refresh]
|
||||
);
|
||||
|
||||
const deleteTag = useCallback(
|
||||
async (id: string) => {
|
||||
await service.deleteTag(id);
|
||||
await refresh();
|
||||
},
|
||||
[service, refresh]
|
||||
);
|
||||
|
||||
return { tags, loading, createTag, deleteTag, refresh };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ThemeProvider } from './theme-provider';
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user