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:
67
src/core/auth/auth-provider.tsx
Normal file
67
src/core/auth/auth-provider.tsx
Normal file
@@ -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;
|
||||
}
|
||||
2
src/core/auth/index.ts
Normal file
2
src/core/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { User, Role, CompanyId, AuthContextValue } from './types';
|
||||
export { AuthProvider, useAuth } from './auth-provider';
|
||||
19
src/core/auth/types.ts
Normal file
19
src/core/auth/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
src/core/feature-flags/feature-gate.tsx
Normal file
14
src/core/feature-flags/feature-gate.tsx
Normal file
@@ -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}</>;
|
||||
}
|
||||
76
src/core/feature-flags/flag-provider.tsx
Normal file
76
src/core/feature-flags/flag-provider.tsx
Normal file
@@ -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;
|
||||
}
|
||||
74
src/core/feature-flags/flag-service.ts
Normal file
74
src/core/feature-flags/flag-service.ts
Normal file
@@ -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);
|
||||
}
|
||||
4
src/core/feature-flags/index.ts
Normal file
4
src/core/feature-flags/index.ts
Normal file
@@ -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';
|
||||
11
src/core/feature-flags/types.ts
Normal file
11
src/core/feature-flags/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/core/feature-flags/use-feature-flag.ts
Normal file
8
src/core/feature-flags/use-feature-flag.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlags } from './flag-provider';
|
||||
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { isEnabled } = useFeatureFlags();
|
||||
return isEnabled(key);
|
||||
}
|
||||
47
src/core/i18n/i18n-provider.tsx
Normal file
47
src/core/i18n/i18n-provider.tsx
Normal file
@@ -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);
|
||||
}
|
||||
2
src/core/i18n/index.ts
Normal file
2
src/core/i18n/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Labels, LabelNamespace } from './types';
|
||||
export { I18nProvider, useI18n, useLabel } from './i18n-provider';
|
||||
109
src/core/i18n/locales/ro.ts
Normal file
109
src/core/i18n/locales/ro.ts
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
3
src/core/i18n/types.ts
Normal file
3
src/core/i18n/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type LabelNamespace = 'common' | 'nav' | 'dashboard' | string;
|
||||
|
||||
export type Labels = Record<string, Record<string, string>>;
|
||||
9
src/core/module-registry/index.ts
Normal file
9
src/core/module-registry/index.ts
Normal file
@@ -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';
|
||||
45
src/core/module-registry/registry.ts
Normal file
45
src/core/module-registry/registry.ts
Normal file
@@ -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(', ')}`);
|
||||
}
|
||||
}
|
||||
32
src/core/module-registry/types.ts
Normal file
32
src/core/module-registry/types.ts
Normal file
@@ -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',
|
||||
};
|
||||
80
src/core/storage/adapters/local-storage.ts
Normal file
80
src/core/storage/adapters/local-storage.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/core/storage/index.ts
Normal file
4
src/core/storage/index.ts
Normal file
@@ -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';
|
||||
33
src/core/storage/storage-provider.tsx
Normal file
33
src/core/storage/storage-provider.tsx
Normal file
@@ -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;
|
||||
}
|
||||
10
src/core/storage/types.ts
Normal file
10
src/core/storage/types.ts
Normal file
@@ -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>;
|
||||
}
|
||||
55
src/core/storage/use-storage.ts
Normal file
55
src/core/storage/use-storage.ts
Normal file
@@ -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]
|
||||
);
|
||||
}
|
||||
3
src/core/tagging/index.ts
Normal file
3
src/core/tagging/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TagService } from './tag-service';
|
||||
export { useTags } from './use-tags';
|
||||
61
src/core/tagging/tag-service.ts
Normal file
61
src/core/tagging/tag-service.ts
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/core/tagging/types.ts
Normal file
27
src/core/tagging/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
45
src/core/tagging/use-tags.ts
Normal file
45
src/core/tagging/use-tags.ts
Normal file
@@ -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 };
|
||||
}
|
||||
1
src/core/theme/index.ts
Normal file
1
src/core/theme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ThemeProvider } from './theme-provider';
|
||||
20
src/core/theme/theme-provider.tsx
Normal file
20
src/core/theme/theme-provider.tsx
Normal file
@@ -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