feat(canary): import operator's React + Vite frontend template
Frontend stack (pre-rebranded by operator before Phase 0):
- React 19 + react-router-dom v7
- TypeScript 5.9 with project references
- Vite 7 (rolldown-vite — Rust port)
- TanStack Query v5 + axios for data
- Zustand v5 for client state
- Zod v4 for runtime validation
- SCSS (Sass), not Tailwind — operator stylistic choice
- Biome 2 (lint + format), Stylelint for SCSS
- react-error-boundary, sonner (toasts)
- react-icons
Source layout (operator-authored skeleton):
- src/App.tsx, main.tsx, config.ts, styles.scss
- src/api/{hooks,types,index.ts} TanStack Query hooks
- src/components/ UI primitives (operator-designed later)
- src/core/{api,app,lib} axios client, routers, utilities
- src/pages/ page-level containers
- src/styles/ SCSS partials
Per implementation plan §16.3, Phase 14 stops at API client only —
operator owns visual/component design from there.
Pre-commit hooks fixed:
- site.webmanifest: missing trailing newline
- index.html: trailing whitespace stripped
- .stylelintignore + stylelint.config.js: chmod -x (no shebang, not executable)
node_modules/ is gitignored.
This commit is contained in:
parent
a2d6f09ab3
commit
a4811d1c04
|
|
@ -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>
|
||||||
|
<!--
|
||||||
|
Canary Token Generator
|
||||||
|
Author(s): © AngelaMos, CarterPerez-dev
|
||||||
|
-->
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="/asset/favicon.ico"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/asset/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/asset/site.webmanifest"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>Canary Token Generator</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": "canary-token-generator",
|
||||||
|
"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.12",
|
||||||
|
"axios": "^1.13.0",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-error-boundary": "^6.0.0",
|
||||||
|
"react-icon": "^1.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"zod": "^4.1.13",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.3.8",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
|
"@types/node": "^24.10.2",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"sass": "^1.95.0",
|
||||||
|
"stylelint": "^16.26.1",
|
||||||
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 823 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
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,36 @@
|
||||||
|
// ===========================
|
||||||
|
// ©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'
|
||||||
|
import '@/core/app/toast.module.scss'
|
||||||
|
|
||||||
|
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 | 2025
|
||||||
|
// index.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
export * from './hooks'
|
||||||
|
export * from './types'
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// index.ts
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* index.tsx
|
||||||
|
*/
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// config.ts
|
||||||
|
// ===================
|
||||||
|
const API_VERSION = 'v1'
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
USERS: {
|
||||||
|
BASE: `/${API_VERSION}/users`,
|
||||||
|
BY_ID: (id: string) => `/${API_VERSION}/users/${id}`,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const QUERY_KEYS = {
|
||||||
|
USERS: {
|
||||||
|
ALL: ['users'] as const,
|
||||||
|
BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
HOME: '/',
|
||||||
|
DASHBOARD: '/dashboard',
|
||||||
|
SETTINGS: '/settings',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
UI: 'ui-storage',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const QUERY_CONFIG = {
|
||||||
|
STALE_TIME: {
|
||||||
|
USER: 1000 * 60 * 5,
|
||||||
|
STATIC: Infinity,
|
||||||
|
FREQUENT: 1000 * 30,
|
||||||
|
},
|
||||||
|
GC_TIME: {
|
||||||
|
DEFAULT: 1000 * 60 * 30,
|
||||||
|
LONG: 1000 * 60 * 60,
|
||||||
|
},
|
||||||
|
RETRY: {
|
||||||
|
DEFAULT: 3,
|
||||||
|
NONE: 0,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const HTTP_STATUS = {
|
||||||
|
OK: 200,
|
||||||
|
CREATED: 201,
|
||||||
|
NO_CONTENT: 204,
|
||||||
|
BAD_REQUEST: 400,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
CONFLICT: 409,
|
||||||
|
TOO_MANY_REQUESTS: 429,
|
||||||
|
INTERNAL_SERVER: 500,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PAGINATION = {
|
||||||
|
DEFAULT_PAGE: 1,
|
||||||
|
DEFAULT_SIZE: 20,
|
||||||
|
MAX_SIZE: 100,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ApiEndpoint = typeof API_ENDPOINTS
|
||||||
|
export type QueryKey = typeof QUERY_KEYS
|
||||||
|
export type Route = typeof ROUTES
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// api.config.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import axios, { type AxiosInstance } from 'axios'
|
||||||
|
|
||||||
|
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' },
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* errors.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
export const ApiErrorCode = {
|
||||||
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||||
|
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
|
||||||
|
AUTHORIZATION_ERROR: 'AUTHORIZATION_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. Please check your internet connection.',
|
||||||
|
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
|
||||||
|
[ApiErrorCode.AUTHENTICATION_ERROR]:
|
||||||
|
'Your session has expired. Please log in again.',
|
||||||
|
[ApiErrorCode.AUTHORIZATION_ERROR]:
|
||||||
|
'You do not have permission to perform this action.',
|
||||||
|
[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 our end. 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'
|
||||||
|
let details: Record<string, string[]> | undefined
|
||||||
|
|
||||||
|
if (data?.detail) {
|
||||||
|
if (typeof data.detail === 'string') {
|
||||||
|
message = data.detail
|
||||||
|
} else if (Array.isArray(data.detail)) {
|
||||||
|
details = { validation: [] }
|
||||||
|
data.detail.forEach((err) => {
|
||||||
|
details?.validation.push(err.msg)
|
||||||
|
})
|
||||||
|
message = 'Validation error'
|
||||||
|
}
|
||||||
|
} else if (data?.message) {
|
||||||
|
message = data.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeMap: Record<number, ApiErrorCode> = {
|
||||||
|
400: ApiErrorCode.VALIDATION_ERROR,
|
||||||
|
401: ApiErrorCode.AUTHENTICATION_ERROR,
|
||||||
|
403: ApiErrorCode.AUTHORIZATION_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 | 2025
|
||||||
|
// index.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
export * from './api.config'
|
||||||
|
export * from './errors'
|
||||||
|
export * from './query.config'
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// 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.AUTHENTICATION_ERROR,
|
||||||
|
ApiErrorCode.AUTHORIZATION_ERROR,
|
||||||
|
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.USER,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
staleTime: QUERY_CONFIG.STALE_TIME.USER,
|
||||||
|
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||||
|
retry: QUERY_CONFIG.RETRY.NONE,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: QUERY_CONFIG.STALE_TIME.USER,
|
||||||
|
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 { createBrowserRouter, type RouteObject } from 'react-router-dom'
|
||||||
|
import { ROUTES } from '@/config'
|
||||||
|
import { Shell } from './shell'
|
||||||
|
|
||||||
|
const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: ROUTES.HOME,
|
||||||
|
lazy: () => import('@/pages/landing'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <Shell />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ROUTES.DASHBOARD,
|
||||||
|
lazy: () => import('@/pages/dashboard'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.SETTINGS,
|
||||||
|
lazy: () => import('@/pages/settings'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
lazy: () => import('@/pages/landing'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const router = createBrowserRouter(routes)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// shell.module.scss
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2026
|
||||||
|
* shell.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
|
import { GiCardAceClubs, GiCardJoker } from 'react-icons/gi'
|
||||||
|
import { LuChevronLeft, LuChevronRight, LuMenu } 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: GiCardJoker },
|
||||||
|
{ path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs },
|
||||||
|
]
|
||||||
|
|
||||||
|
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}>NavBar Template</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,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// toast.module.scss
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// index.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
export * from './shell.ui.store'
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* ui.store.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { devtools, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
theme: Theme
|
||||||
|
sidebarOpen: boolean
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
setSidebarOpen: (open: boolean) => void
|
||||||
|
toggleSidebarCollapsed: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: false,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
|
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
|
||||||
|
|
||||||
|
toggleSidebar: () =>
|
||||||
|
set(
|
||||||
|
(state) => ({ sidebarOpen: !state.sidebarOpen }),
|
||||||
|
false,
|
||||||
|
'ui/toggleSidebar'
|
||||||
|
),
|
||||||
|
|
||||||
|
setSidebarOpen: (open) =>
|
||||||
|
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
|
||||||
|
|
||||||
|
toggleSidebarCollapsed: () =>
|
||||||
|
set(
|
||||||
|
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
|
||||||
|
false,
|
||||||
|
'ui/toggleSidebarCollapsed'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ui-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
theme: state.theme,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ name: 'UIStore' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useTheme = (): Theme => useUIStore((s) => s.theme)
|
||||||
|
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
|
||||||
|
export const useSidebarCollapsed = (): boolean =>
|
||||||
|
useUIStore((s) => s.sidebarCollapsed)
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
// ===========================
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// 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,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// dashboard.module.scss
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2026
|
||||||
|
* index.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import styles from './dashboard.module.scss'
|
||||||
|
|
||||||
|
const AVAILABLE_STORES = [
|
||||||
|
{
|
||||||
|
name: 'useUIStore()',
|
||||||
|
file: 'core/lib/shell.ui.store.ts',
|
||||||
|
description: 'Theme, sidebar open/collapsed state',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SUGGESTED_FEATURES = [
|
||||||
|
'Stats and metrics',
|
||||||
|
'Recent activity feed',
|
||||||
|
'Quick actions',
|
||||||
|
'Charts and analytics',
|
||||||
|
'Notifications overview',
|
||||||
|
'Task/project summary',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Component(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.title}>Welcome</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Template page — build your dashboard here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Available Stores</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{AVAILABLE_STORES.map((store) => (
|
||||||
|
<div key={store.name} className={styles.card}>
|
||||||
|
<code className={styles.hookName}>{store.name}</code>
|
||||||
|
<p className={styles.description}>{store.description}</p>
|
||||||
|
<span className={styles.file}>{store.file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Suggested Features</h2>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{SUGGESTED_FEATURES.map((feature) => (
|
||||||
|
<li key={feature}>{feature}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = 'Dashboard'
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import styles from './landing.module.scss'
|
||||||
|
|
||||||
|
export function Component(): React.ReactElement {
|
||||||
|
return <div className={styles.page} />
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = 'Landing'
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// landing.module.scss
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2026
|
||||||
|
* index.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import styles from './settings.module.scss'
|
||||||
|
|
||||||
|
const AVAILABLE_STORES = [
|
||||||
|
{
|
||||||
|
name: 'useUIStore()',
|
||||||
|
file: 'core/lib/shell.ui.store.ts',
|
||||||
|
description: 'Theme, sidebar open/collapsed state',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Component(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.title}>Settings</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Template page — available stores for building your settings UI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Available Stores</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{AVAILABLE_STORES.map((store) => (
|
||||||
|
<div key={store.name} className={styles.card}>
|
||||||
|
<code className={styles.hookName}>{store.name}</code>
|
||||||
|
<p className={styles.description}>{store.description}</p>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
<span className={styles.file}>{store.file}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Suggested Features</h2>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>Profile form</li>
|
||||||
|
<li>Theme toggle (dark/light)</li>
|
||||||
|
<li>Notification settings</li>
|
||||||
|
<li>Application preferences</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = 'Settings'
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// settings.module.scss
|
||||||
|
// ===================
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// styles.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@forward 'styles/tokens';
|
||||||
|
@forward 'styles/fonts';
|
||||||
|
@forward 'styles/mixins';
|
||||||
|
|
||||||
|
@use 'styles/reset';
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// _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 | 2025
|
||||||
|
// _index.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@forward 'tokens';
|
||||||
|
@forward 'fonts';
|
||||||
|
@forward 'mixins';
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// _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,105 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// _reset.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::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: 1.5;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.2;
|
||||||
|
text-wrap: balance;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
picture,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// _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 | 2025
|
||||||
|
* 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