feat(frontend): strip auth and configure for AngelusVigil
Strip the fullstack template of all auth/user/admin code and reconfigure for the AngelusVigil threat detection dashboard with 3 pages.
This commit is contained in:
parent
d471de31fd
commit
17efa613bf
|
|
@ -0,0 +1,15 @@
|
|||
node_modules
|
||||
build
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
coverage
|
||||
.nyc_output
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.vite
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# ©AngelaMos | 2025
|
||||
# .stylelintignore
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# JS/TS files
|
||||
**/*.js
|
||||
**/*.jsx
|
||||
**/*.ts
|
||||
**/*.tsx
|
||||
|
||||
# Generated files
|
||||
*.min.css
|
||||
|
||||
# Error system styles - ignore from linting
|
||||
src/core/app/_toastStyles.scss
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 82,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": { "maxAllowedComplexity": 25 }
|
||||
},
|
||||
"noForEach": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noUndeclaredVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": { "syntax": "shorthand" }
|
||||
},
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noDebugger": "error",
|
||||
"noConsole": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAssignInExpressions": "error",
|
||||
"noDoubleEquals": "error",
|
||||
"noRedeclare": "error",
|
||||
"noVar": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useAltText": "error",
|
||||
"useAnchorContent": "error",
|
||||
"useKeyWithClickEvents": "error",
|
||||
"noStaticElementInteractions": "error",
|
||||
"useButtonType": "error",
|
||||
"useValidAnchor": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["src/main.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
AngelusVigil
|
||||
Author(s): © AngelaMos, CarterPerez-dev
|
||||
-->
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="/assets/favicon.ico"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
href="/assets/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/assets/site.webmanifest"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>AngelusVigil</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="*Cracked*"
|
||||
/>
|
||||
<meta
|
||||
name="author"
|
||||
content=" ©AngelaMos | 2026 | CarterPerez-dev"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "frontend-template",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"typecheck": "tsc -b",
|
||||
"lint:scss": "stylelint '**/*.scss'",
|
||||
"lint:scss:fix": "stylelint '**/*.scss' --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-icon": "^1.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"sass": "^1.97.3",
|
||||
"stylelint": "^17.0.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 279 B |
Binary file not shown.
|
After Width: | Height: | Size: 565 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// App.tsx
|
||||
// ===================
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import { queryClient } from '@/core/api'
|
||||
import { router } from '@/core/app/routers'
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="app">
|
||||
<RouterProvider router={router} />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
duration={2000}
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(0, 0%, 12.2%)',
|
||||
border: '1px solid hsl(0, 0%, 18%)',
|
||||
color: 'hsl(0, 0%, 98%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* ©AngelaMos | 2026
|
||||
* index.tsx
|
||||
*/
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// config.ts
|
||||
// ===================
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
HEALTH: '/health',
|
||||
READY: '/ready',
|
||||
THREATS: {
|
||||
LIST: '/threats',
|
||||
BY_ID: (id: string) => `/threats/${id}`,
|
||||
},
|
||||
STATS: '/stats',
|
||||
MODELS: {
|
||||
STATUS: '/models/status',
|
||||
RETRAIN: '/models/retrain',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const WS_ENDPOINTS = {
|
||||
ALERTS: '/ws/alerts',
|
||||
} as const
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
THREATS: {
|
||||
ALL: ['threats'] as const,
|
||||
LIST: (params: Record<string, unknown>) =>
|
||||
[...QUERY_KEYS.THREATS.ALL, 'list', params] as const,
|
||||
BY_ID: (id: string) =>
|
||||
[...QUERY_KEYS.THREATS.ALL, 'detail', id] as const,
|
||||
},
|
||||
STATS: {
|
||||
ALL: ['stats'] as const,
|
||||
BY_RANGE: (range: string) =>
|
||||
[...QUERY_KEYS.STATS.ALL, range] as const,
|
||||
},
|
||||
MODELS: {
|
||||
ALL: ['models'] as const,
|
||||
STATUS: () => [...QUERY_KEYS.MODELS.ALL, 'status'] as const,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const ROUTES = {
|
||||
DASHBOARD: '/',
|
||||
THREATS: '/threats',
|
||||
MODELS: '/models',
|
||||
} as const
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
UI: 'ui-storage',
|
||||
} as const
|
||||
|
||||
export const QUERY_CONFIG = {
|
||||
STALE_TIME: {
|
||||
STANDARD: 1000 * 60 * 5,
|
||||
FREQUENT: 1000 * 30,
|
||||
STATIC: Infinity,
|
||||
},
|
||||
GC_TIME: {
|
||||
DEFAULT: 1000 * 60 * 30,
|
||||
LONG: 1000 * 60 * 60,
|
||||
},
|
||||
RETRY: {
|
||||
DEFAULT: 3,
|
||||
NONE: 0,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PAGINATION = {
|
||||
DEFAULT_LIMIT: 50,
|
||||
MAX_LIMIT: 100,
|
||||
} as const
|
||||
|
||||
export const ALERTS = {
|
||||
MAX_ITEMS: 50,
|
||||
RECONNECT_BASE_MS: 1000,
|
||||
RECONNECT_MAX_MS: 30000,
|
||||
} as const
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// api.config.ts
|
||||
// ===================
|
||||
|
||||
import axios, { type AxiosError, type AxiosInstance } from 'axios'
|
||||
import { transformAxiosError } from './errors'
|
||||
|
||||
const getBaseURL = (): string => {
|
||||
return import.meta.env.VITE_API_URL ?? '/api'
|
||||
}
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError): Promise<never> => {
|
||||
return Promise.reject(transformAxiosError(error))
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// errors.ts
|
||||
// ===================
|
||||
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export const ApiErrorCode = {
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
} as const
|
||||
|
||||
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiErrorCode
|
||||
readonly statusCode: number
|
||||
readonly details?: Record<string, string[]>
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiErrorCode,
|
||||
statusCode: number,
|
||||
details?: Record<string, string[]>,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
|
||||
getUserMessage(): string {
|
||||
const messages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.NETWORK_ERROR]:
|
||||
'Unable to connect to the server. Check your connection.',
|
||||
[ApiErrorCode.VALIDATION_ERROR]: 'Invalid request parameters.',
|
||||
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
|
||||
[ApiErrorCode.CONFLICT]:
|
||||
'This operation conflicts with an existing resource.',
|
||||
[ApiErrorCode.RATE_LIMITED]:
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
[ApiErrorCode.SERVER_ERROR]:
|
||||
'Something went wrong on the server. Please try again later.',
|
||||
[ApiErrorCode.UNKNOWN_ERROR]:
|
||||
'An unexpected error occurred. Please try again.',
|
||||
}
|
||||
return messages[this.code]
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
detail?: string | { msg: string; type: string }[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
|
||||
if (!error.response) {
|
||||
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
||||
}
|
||||
|
||||
const { status } = error.response
|
||||
const data = error.response.data as ApiErrorResponse | undefined
|
||||
let message = 'An error occurred'
|
||||
const details: Record<string, string[]> | undefined = undefined
|
||||
|
||||
if (data?.detail) {
|
||||
if (typeof data.detail === 'string') {
|
||||
message = data.detail
|
||||
}
|
||||
} else if (data?.message) {
|
||||
message = data.message
|
||||
}
|
||||
|
||||
const codeMap: Record<number, ApiErrorCode> = {
|
||||
400: ApiErrorCode.VALIDATION_ERROR,
|
||||
404: ApiErrorCode.NOT_FOUND,
|
||||
409: ApiErrorCode.CONFLICT,
|
||||
429: ApiErrorCode.RATE_LIMITED,
|
||||
500: ApiErrorCode.SERVER_ERROR,
|
||||
502: ApiErrorCode.SERVER_ERROR,
|
||||
503: ApiErrorCode.SERVER_ERROR,
|
||||
504: ApiErrorCode.SERVER_ERROR,
|
||||
}
|
||||
|
||||
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
|
||||
|
||||
return new ApiError(message, code, status, details)
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-query' {
|
||||
interface Register {
|
||||
defaultError: ApiError
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// query.config.ts
|
||||
// ===================
|
||||
|
||||
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { QUERY_CONFIG } from '@/config'
|
||||
import { ApiError, ApiErrorCode } from './errors'
|
||||
|
||||
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
|
||||
ApiErrorCode.NOT_FOUND,
|
||||
ApiErrorCode.VALIDATION_ERROR,
|
||||
] as const
|
||||
|
||||
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
|
||||
if (error instanceof ApiError) {
|
||||
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
|
||||
}
|
||||
|
||||
const calculateRetryDelay = (attemptIndex: number): number => {
|
||||
const baseDelay = 1000
|
||||
const maxDelay = 30000
|
||||
return Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
|
||||
}
|
||||
|
||||
const handleQueryCacheError = (
|
||||
error: Error,
|
||||
query: { state: { data: unknown } },
|
||||
): void => {
|
||||
if (query.state.data !== undefined) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? error.getUserMessage()
|
||||
: 'Background update failed'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMutationCacheError = (
|
||||
error: Error,
|
||||
_variables: unknown,
|
||||
_context: unknown,
|
||||
mutation: { options: { onError?: unknown } },
|
||||
): void => {
|
||||
if (mutation.options.onError === undefined) {
|
||||
const message =
|
||||
error instanceof ApiError ? error.getUserMessage() : 'Operation failed'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const QUERY_STRATEGIES = {
|
||||
standard: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.STANDARD,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||
},
|
||||
frequent: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||
refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT,
|
||||
},
|
||||
static: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.STATIC,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.LONG,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.STANDARD,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||
retry: shouldRetryQuery,
|
||||
retryDelay: calculateRetryDelay,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: QUERY_CONFIG.RETRY.NONE,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: handleQueryCacheError,
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: handleMutationCacheError,
|
||||
}),
|
||||
})
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// routers.tsx
|
||||
// ===================
|
||||
|
||||
import { Navigate, createBrowserRouter, type RouteObject } from 'react-router-dom'
|
||||
import { ROUTES } from '@/config'
|
||||
import { Shell } from './shell'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <Shell />,
|
||||
children: [
|
||||
{
|
||||
path: ROUTES.DASHBOARD,
|
||||
lazy: () => import('@/pages/dashboard'),
|
||||
},
|
||||
{
|
||||
path: ROUTES.THREATS,
|
||||
lazy: () => import('@/pages/threats'),
|
||||
},
|
||||
{
|
||||
path: ROUTES.MODELS,
|
||||
lazy: () => import('@/pages/models'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to={ROUTES.DASHBOARD} replace />,
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createBrowserRouter(routes)
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// shell.module.scss
|
||||
// ===================
|
||||
|
||||
@use '@/styles' as *;
|
||||
|
||||
$sidebar-width: 240px;
|
||||
$sidebar-collapsed-width: 64px;
|
||||
$header-height: 56px;
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: $sidebar-width;
|
||||
background: $bg-surface-100;
|
||||
border-right: 1px solid $border-default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: $z-fixed;
|
||||
@include transition-fast;
|
||||
|
||||
&.collapsed {
|
||||
width: $sidebar-collapsed-width;
|
||||
}
|
||||
|
||||
@include breakpoint-down('sm') {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
height: $header-height;
|
||||
padding: 0 $space-3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid $border-default;
|
||||
|
||||
.sidebar.collapsed & {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-default;
|
||||
@include transition-fast;
|
||||
|
||||
.sidebar.collapsed & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
padding: $space-3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
padding: $space-2 $space-3;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
@include transition-fast;
|
||||
|
||||
@include hover {
|
||||
background: $bg-surface-200;
|
||||
color: $text-default;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $bg-selection;
|
||||
color: $text-default;
|
||||
}
|
||||
|
||||
.sidebar.collapsed & {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
@include transition-fast;
|
||||
|
||||
.sidebar.collapsed & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.adminItem {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid $border-default;
|
||||
padding-top: $space-3;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: $radius-md;
|
||||
color: $text-light;
|
||||
@include flex-center;
|
||||
@include transition-fast;
|
||||
|
||||
svg {
|
||||
width: 23.5px;
|
||||
height: 23.5px;
|
||||
}
|
||||
|
||||
@include hover {
|
||||
background: $bg-surface-200;
|
||||
color: $text-default;
|
||||
}
|
||||
|
||||
@include breakpoint-down('sm') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarFooter {
|
||||
padding: $space-3;
|
||||
border-top: 1px solid $border-default;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
padding: $space-3;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-default;
|
||||
@include transition-fast;
|
||||
|
||||
@include hover {
|
||||
background: $bg-surface-200;
|
||||
}
|
||||
|
||||
.sidebar.collapsed & {
|
||||
justify-content: center;
|
||||
|
||||
.logoutText {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logoutIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logoutText {
|
||||
font-weight: $font-weight-medium;
|
||||
@include transition-fast;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0, 0, 0, 50%);
|
||||
z-index: calc($z-fixed - 1);
|
||||
display: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
@include breakpoint-down('sm') {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: $sidebar-width;
|
||||
min-width: 0;
|
||||
@include transition-fast;
|
||||
|
||||
&.collapsed {
|
||||
margin-left: $sidebar-collapsed-width;
|
||||
}
|
||||
|
||||
@include breakpoint-down('sm') {
|
||||
margin-left: 0;
|
||||
|
||||
&.collapsed {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: $header-height;
|
||||
background: $bg-surface-100;
|
||||
border-bottom: 1px solid $border-default;
|
||||
z-index: $z-sticky;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 $space-4;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: $radius-md;
|
||||
color: $text-light;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include transition-fast;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@include hover {
|
||||
background: $bg-surface-200;
|
||||
color: $text-default;
|
||||
}
|
||||
|
||||
@media (width <= 479px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-default;
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-surface-300;
|
||||
color: $text-light;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
@include flex-center;
|
||||
cursor: pointer;
|
||||
@include transition-fast;
|
||||
|
||||
@include hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@include flex-center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.error {
|
||||
@include flex-column-center;
|
||||
height: 100%;
|
||||
gap: $space-4;
|
||||
padding: $space-6;
|
||||
color: $error-default;
|
||||
|
||||
h2 {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
padding: $space-4;
|
||||
background: $bg-surface-200;
|
||||
border-radius: $radius-lg;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// shell.tsx
|
||||
// ===================
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { LuChevronLeft, LuChevronRight, LuCpu, LuLayoutDashboard, LuMenu, LuShield } from 'react-icons/lu'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { ROUTES } from '@/config'
|
||||
import { useUIStore } from '@/core/lib'
|
||||
import styles from './shell.module.scss'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: ROUTES.DASHBOARD, label: 'Dashboard', icon: LuLayoutDashboard },
|
||||
{ path: ROUTES.THREATS, label: 'Threats', icon: LuShield },
|
||||
{ path: ROUTES.MODELS, label: 'Models', icon: LuCpu },
|
||||
]
|
||||
|
||||
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<h2>Something went wrong</h2>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellLoading(): React.ReactElement {
|
||||
return <div className={styles.loading}>Loading...</div>
|
||||
}
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
const item = NAV_ITEMS.find((i) => i.path === pathname)
|
||||
return item?.label ?? 'Dashboard'
|
||||
}
|
||||
|
||||
export function Shell(): React.ReactElement {
|
||||
const location = useLocation()
|
||||
const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } =
|
||||
useUIStore()
|
||||
|
||||
const pageTitle = getPageTitle(location.pathname)
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<aside
|
||||
className={`${styles.sidebar} ${sidebarOpen ? styles.open : ''} ${sidebarCollapsed ? styles.collapsed : ''}`}
|
||||
>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<span className={styles.logo}>
|
||||
{sidebarCollapsed ? 'AV' : 'AngelusVigil'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
onClick={toggleSidebarCollapsed}
|
||||
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? <LuChevronRight /> : <LuChevronLeft />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
onClick={() => sidebarOpen && toggleSidebar()}
|
||||
>
|
||||
<item.icon className={styles.navIcon} />
|
||||
<span className={styles.navLabel}>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.overlay}
|
||||
onClick={toggleSidebar}
|
||||
onKeyDown={(e) => e.key === 'Escape' && toggleSidebar()}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${styles.main} ${sidebarCollapsed ? styles.collapsed : ''}`}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuBtn}
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<LuMenu />
|
||||
</button>
|
||||
<h1 className={styles.pageTitle}>{pageTitle}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
|
||||
<Suspense fallback={<ShellLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// toast.module.scss
|
||||
// ===================
|
||||
|
||||
@use '@/styles' as *;
|
||||
|
||||
:global {
|
||||
[data-sonner-toaster] {
|
||||
--normal-bg: #{$bg-surface-100};
|
||||
--normal-border: #{$border-default};
|
||||
--normal-text: #{$text-default};
|
||||
|
||||
--success-bg: #{$bg-surface-100};
|
||||
--success-border: #{$border-default};
|
||||
--success-text: #{$text-default};
|
||||
|
||||
--error-bg: #{$bg-surface-100};
|
||||
--error-border: #{$error-default};
|
||||
--error-text: #{$text-default};
|
||||
|
||||
--warning-bg: #{$bg-surface-100};
|
||||
--warning-border: #{$border-default};
|
||||
--warning-text: #{$text-default};
|
||||
|
||||
--info-bg: #{$bg-surface-100};
|
||||
--info-border: #{$border-default};
|
||||
--info-text: #{$text-default};
|
||||
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
[data-sonner-toast] {
|
||||
border-radius: $radius-md;
|
||||
padding: $space-3 $space-4;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $border-default;
|
||||
background: $bg-surface-100;
|
||||
color: $text-default;
|
||||
|
||||
[data-title] {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
[data-description] {
|
||||
color: $text-light;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
[data-close-button] {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: $text-muted;
|
||||
@include transition-fast;
|
||||
|
||||
@include hover {
|
||||
color: $text-default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type='error'] {
|
||||
border-color: $error-default;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// ===========================
|
||||
// ©AngelaMos | 2026
|
||||
// main.tsx
|
||||
// ===========================
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.scss'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// dashboard.module.scss
|
||||
// ===================
|
||||
|
||||
@use '@/styles' as *;
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: $space-6;
|
||||
background-color: $bg-default;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: $space-6;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-default;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-lighter;
|
||||
}
|
||||
|
||||
.userCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
padding: $space-5;
|
||||
background: $bg-surface-100;
|
||||
border: 1px solid $border-default;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-surface-300;
|
||||
color: $text-default;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
@include flex-column;
|
||||
gap: $space-1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-default;
|
||||
@include truncate;
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
@include truncate;
|
||||
}
|
||||
|
||||
.userRole {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: $space-0-5 $space-2;
|
||||
background: $bg-surface-200;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-lighter;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $tracking-wide;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-light;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $tracking-wide;
|
||||
margin-bottom: $space-4;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: $space-4;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: $space-4;
|
||||
background: $bg-surface-100;
|
||||
border: 1px solid $border-default;
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
|
||||
.hookName {
|
||||
display: inline-block;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-default;
|
||||
background: $bg-surface-200;
|
||||
padding: $space-1 $space-2;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
margin-bottom: $space-2;
|
||||
line-height: $line-height-relaxed;
|
||||
}
|
||||
|
||||
.file {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-muted;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
.list {
|
||||
@include flex-column;
|
||||
gap: $space-2;
|
||||
padding-left: $space-5;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return <div>Dashboard</div>
|
||||
}
|
||||
|
||||
Component.displayName = 'DashboardPage'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return <div>Models</div>
|
||||
}
|
||||
|
||||
Component.displayName = 'ModelsPage'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return <div>Threats</div>
|
||||
}
|
||||
|
||||
Component.displayName = 'ThreatsPage'
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// styles.scss
|
||||
// ===================
|
||||
|
||||
@forward 'styles/tokens';
|
||||
@forward 'styles/fonts';
|
||||
@forward 'styles/mixins';
|
||||
|
||||
@use 'styles/reset';
|
||||
@use 'styles/tokens' as *;
|
||||
@use 'styles/fonts' as *;
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $bg-default;
|
||||
}
|
||||
|
||||
.app {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $bg-default;
|
||||
color: $text-default;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _fonts.scss
|
||||
// ===================
|
||||
|
||||
@use 'tokens' as *;
|
||||
|
||||
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _mixins.scss
|
||||
// ===================
|
||||
|
||||
@use 'sass:map';
|
||||
@use 'sass:list';
|
||||
@use 'tokens' as *;
|
||||
|
||||
$breakpoints: (
|
||||
'xs': $breakpoint-xs,
|
||||
'sm': $breakpoint-sm,
|
||||
'md': $breakpoint-md,
|
||||
'lg': $breakpoint-lg,
|
||||
'xl': $breakpoint-xl,
|
||||
'2xl': $breakpoint-2xl,
|
||||
);
|
||||
|
||||
@mixin breakpoint-up($size) {
|
||||
@media (min-width: map.get($breakpoints, $size)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin breakpoint-down($size) {
|
||||
@media (width < map.get($breakpoints, $size)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@mixin flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@mixin flex-column-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@mixin truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin line-clamp($lines: 2) {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: #{$lines};
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin transition-fast {
|
||||
transition-property: background-color, border-color, color, opacity;
|
||||
transition-duration: $duration-fast;
|
||||
transition-timing-function: $ease-out;
|
||||
}
|
||||
|
||||
@mixin transition-normal {
|
||||
transition-property: background-color, border-color, color, opacity;
|
||||
transition-duration: $duration-normal;
|
||||
transition-timing-function: $ease-out;
|
||||
}
|
||||
|
||||
@mixin absolute-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@mixin absolute-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _reset.scss
|
||||
// ===================
|
||||
|
||||
@use 'tokens' as *;
|
||||
@use 'fonts' as *;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-moz-text-size-adjust: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
interpolate-size: allow-keywords;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
line-height: $line-height-normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
background-color: $bg-default;
|
||||
color: $text-default;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: $line-height-tight;
|
||||
text-wrap: balance;
|
||||
overflow-wrap: break-word;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='number'],
|
||||
input[type='tel'],
|
||||
input[type='url'],
|
||||
textarea,
|
||||
select {
|
||||
font-size: $font-size-sm;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
textarea:not([rows]) {
|
||||
min-height: 10em;
|
||||
}
|
||||
|
||||
:target {
|
||||
scroll-margin-block: 5ex;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid $border-strong;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: max(0px, env(safe-area-inset-left));
|
||||
padding-right: max(0px, env(safe-area-inset-right));
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: $border-default;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: $border-strong;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: $bg-selection;
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _tokens.scss
|
||||
// ===================
|
||||
|
||||
// ============================================================================
|
||||
// SPACING (8px base system)
|
||||
// ============================================================================
|
||||
$space-0: 0;
|
||||
$space-px: 1px;
|
||||
$space-0-5: 0.125rem;
|
||||
$space-1: 0.25rem;
|
||||
$space-1-5: 0.375rem;
|
||||
$space-2: 0.5rem;
|
||||
$space-2-5: 0.625rem;
|
||||
$space-3: 0.75rem;
|
||||
$space-3-5: 0.875rem;
|
||||
$space-4: 1rem;
|
||||
$space-5: 1.25rem;
|
||||
$space-6: 1.5rem;
|
||||
$space-7: 1.75rem;
|
||||
$space-8: 2rem;
|
||||
$space-9: 2.25rem;
|
||||
$space-10: 2.5rem;
|
||||
$space-11: 2.75rem;
|
||||
$space-12: 3rem;
|
||||
$space-14: 3.5rem;
|
||||
$space-16: 4rem;
|
||||
$space-20: 5rem;
|
||||
$space-24: 6rem;
|
||||
$space-28: 7rem;
|
||||
$space-32: 8rem;
|
||||
|
||||
// ============================================================================
|
||||
// TYPOGRAPHY SCALE
|
||||
// ============================================================================
|
||||
$font-size-3xs: 0.625rem;
|
||||
$font-size-2xs: 0.6875rem;
|
||||
$font-size-xs: 0.75rem;
|
||||
$font-size-sm: 0.875rem;
|
||||
$font-size-base: 1rem;
|
||||
$font-size-lg: 1.125rem;
|
||||
$font-size-xl: 1.25rem;
|
||||
$font-size-2xl: 1.5rem;
|
||||
$font-size-3xl: 1.875rem;
|
||||
$font-size-4xl: 2.25rem;
|
||||
$font-size-5xl: 3rem;
|
||||
|
||||
// ============================================================================
|
||||
// FONT WEIGHTS
|
||||
// ============================================================================
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
// ============================================================================
|
||||
// LINE HEIGHTS
|
||||
// ============================================================================
|
||||
$line-height-none: 1;
|
||||
$line-height-tight: 1.2;
|
||||
$line-height-snug: 1.375;
|
||||
$line-height-normal: 1.5;
|
||||
$line-height-relaxed: 1.625;
|
||||
|
||||
// ============================================================================
|
||||
// LETTER SPACING
|
||||
// ============================================================================
|
||||
$tracking-tighter: -0.05em;
|
||||
$tracking-tight: -0.025em;
|
||||
$tracking-normal: 0;
|
||||
$tracking-wide: 0.025em;
|
||||
$tracking-wider: 0.05em;
|
||||
|
||||
// ============================================================================
|
||||
// COLORS
|
||||
// ============================================================================
|
||||
$white: hsl(0, 0%, 100%);
|
||||
$black: hsl(0, 0%, 0%);
|
||||
|
||||
// Auth
|
||||
$bg-page: hsl(0, 0%, 10.5%);
|
||||
$bg-card: hsl(0, 0%, 6.2%);
|
||||
|
||||
// Home/landing
|
||||
$bg-landing: hsl(0, 0%, 10.8%);
|
||||
|
||||
$bg-default: hsl(0, 0%, 7.1%);
|
||||
$bg-alternative: hsl(0, 0%, 5.9%);
|
||||
$bg-surface-75: hsl(0, 0%, 9%);
|
||||
$bg-surface-100: hsl(0, 0%, 12.2%);
|
||||
$bg-surface-200: hsl(0, 0%, 14.1%);
|
||||
$bg-surface-300: hsl(0, 0%, 16.1%);
|
||||
$bg-control: hsl(0, 0%, 10%);
|
||||
$bg-selection: hsl(0, 0%, 19.2%);
|
||||
$bg-overlay: hsl(0, 0%, 14.1%);
|
||||
$bg-overlay-hover: hsl(0, 0%, 18%);
|
||||
|
||||
$border-muted: hsl(0, 0%, 11.1%);
|
||||
$border-default: hsl(0, 0%, 18%);
|
||||
$border-strong: hsl(0, 0%, 22.4%);
|
||||
$border-stronger: hsl(0, 0%, 27.1%);
|
||||
$border-control: hsl(0, 0%, 22.4%);
|
||||
|
||||
$text-default: hsl(0, 0%, 98%);
|
||||
$text-light: hsl(0, 0%, 70.6%);
|
||||
$text-lighter: hsl(0, 0%, 53.7%);
|
||||
$text-muted: hsl(0, 0%, 30.2%);
|
||||
|
||||
$error-default: hsl(0, 72%, 51%);
|
||||
$error-light: hsl(0, 72%, 65%);
|
||||
|
||||
// ============================================================================
|
||||
// BORDER RADIUS
|
||||
// ============================================================================
|
||||
$radius-none: 0;
|
||||
$radius-xs: 2px;
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 6px;
|
||||
$radius-lg: 8px;
|
||||
$radius-xl: 12px;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// ============================================================================
|
||||
// Z-INDEX SCALE
|
||||
// ============================================================================
|
||||
$z-hide: -1;
|
||||
$z-base: 0;
|
||||
$z-dropdown: 100;
|
||||
$z-sticky: 200;
|
||||
$z-fixed: 300;
|
||||
$z-overlay: 400;
|
||||
$z-modal: 500;
|
||||
$z-popover: 600;
|
||||
$z-tooltip: 700;
|
||||
$z-toast: 800;
|
||||
$z-max: 9999;
|
||||
|
||||
// ============================================================================
|
||||
// TRANSITIONS
|
||||
// ============================================================================
|
||||
$duration-instant: 0ms;
|
||||
$duration-fast: 100ms;
|
||||
$duration-normal: 150ms;
|
||||
$duration-slow: 200ms;
|
||||
|
||||
$ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// ============================================================================
|
||||
// BREAKPOINTS
|
||||
// ============================================================================
|
||||
$breakpoint-xs: 360px;
|
||||
$breakpoint-sm: 480px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
$breakpoint-xl: 1280px;
|
||||
$breakpoint-2xl: 1536px;
|
||||
|
||||
// ============================================================================
|
||||
// CONTAINER WIDTHS
|
||||
// ============================================================================
|
||||
$container-xs: 20rem;
|
||||
$container-sm: 24rem;
|
||||
$container-md: 28rem;
|
||||
$container-lg: 32rem;
|
||||
$container-xl: 36rem;
|
||||
$container-2xl: 42rem;
|
||||
$container-full: 100%;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// ©AngelaMos | 2025
|
||||
// stylelint.config.js
|
||||
|
||||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
|
||||
rules: {
|
||||
'block-no-empty': true,
|
||||
'declaration-no-important': true,
|
||||
'color-no-invalid-hex': true,
|
||||
'property-no-unknown': true,
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['global'],
|
||||
},
|
||||
],
|
||||
|
||||
'selector-class-pattern': [
|
||||
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
|
||||
{
|
||||
message:
|
||||
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
|
||||
},
|
||||
],
|
||||
|
||||
'value-keyword-case': [
|
||||
'lower',
|
||||
{
|
||||
camelCaseSvgKeywords: true,
|
||||
ignoreKeywords: [
|
||||
'BlinkMacSystemFont',
|
||||
'SFMono-Regular',
|
||||
'Menlo',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'Roboto',
|
||||
'Arial',
|
||||
'Helvetica',
|
||||
'Times',
|
||||
'Georgia',
|
||||
'Verdana',
|
||||
'Tahoma',
|
||||
'Trebuchet',
|
||||
'Impact',
|
||||
'Comic',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'property-no-vendor-prefix': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
|
||||
},
|
||||
],
|
||||
'value-no-vendor-prefix': true,
|
||||
'selector-no-vendor-prefix': true,
|
||||
|
||||
'property-no-deprecated': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['clip'],
|
||||
},
|
||||
],
|
||||
|
||||
'container-name-pattern': null,
|
||||
'layer-name-pattern': null,
|
||||
|
||||
'scss/at-rule-no-unknown': true,
|
||||
'scss/declaration-nested-properties-no-divided-groups': true,
|
||||
'scss/dollar-variable-no-missing-interpolation': true,
|
||||
'scss/dollar-variable-empty-line-before': null,
|
||||
|
||||
'declaration-empty-line-before': null,
|
||||
'custom-property-empty-line-before': null,
|
||||
|
||||
'no-descending-specificity': null,
|
||||
|
||||
'media-feature-name-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreMediaFeatureNames: ['map'],
|
||||
},
|
||||
],
|
||||
|
||||
'color-function-notation': null,
|
||||
'hue-degree-notation': null,
|
||||
},
|
||||
ignoreFiles: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'**/*.js',
|
||||
'**/*.ts',
|
||||
'**/*.tsx',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
|
||||
rules: {
|
||||
'declaration-no-important': null,
|
||||
'scss/comment-no-empty': null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* ©AngelaMos | 2026
|
||||
* vite.config.ts
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, path.resolve(__dirname, '..'), '')
|
||||
const isDev = mode === 'development'
|
||||
|
||||
return {
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
target: 'esnext',
|
||||
cssTarget: 'chrome100',
|
||||
sourcemap: isDev ? true : 'hidden',
|
||||
minify: 'oxc',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string): string | undefined {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('react-dom') || id.includes('react-router')) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
if (id.includes('@tanstack/react-query')) {
|
||||
return 'vendor-query'
|
||||
}
|
||||
if (id.includes('zustand')) {
|
||||
return 'vendor-state'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
preview: {
|
||||
port: 4173,
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue