mirror: import production source 3/3
This commit is contained in:
parent
17e3db9970
commit
2fdd51f921
40
Frontend/src/app/routes/learner-routes.tsx
Normal file
40
Frontend/src/app/routes/learner-routes.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { RouteObject } from 'react-router'
|
||||||
|
|
||||||
|
import { LearnerLayout } from '../layouts/LearnerLayout'
|
||||||
|
import { LearnerDashboardPage } from '../pages/LearnerDashboardPage'
|
||||||
|
import { LearnerLiveCoursesPage } from '../pages/LearnerLiveCoursesPage'
|
||||||
|
import { LearnerCourseDetailPage } from '../pages/LearnerCourseDetailPage'
|
||||||
|
import { LearnerScormCoursesPage } from '../pages/LearnerScormCoursesPage'
|
||||||
|
import { LearnerScormLaunchPage } from '../pages/LearnerScormLaunchPage'
|
||||||
|
import { LearnerLiveSessionsPage } from '../pages/LearnerLiveSessionsPage'
|
||||||
|
import { LearnerLiveSessionPage } from '../pages/LearnerLiveSessionPage'
|
||||||
|
import { LearnerCertificatesPage } from '../pages/LearnerCertificatesPage'
|
||||||
|
import { LearnerCommunityPage } from '../pages/LearnerCommunityPage'
|
||||||
|
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
|
||||||
|
import { LearnerProfilePage } from '../pages/LearnerProfilePage'
|
||||||
|
import { LearnerNotificationsPage } from '../pages/LearnerNotificationsPage'
|
||||||
|
|
||||||
|
function LearnerBillingNotEnabledPage() {
|
||||||
|
return <FeatureGuardPage title="Learner Billing" summary="Self-service billing is not available in the learner portal yet." />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const learnerRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/learn',
|
||||||
|
Component: LearnerLayout,
|
||||||
|
children: [
|
||||||
|
{ index: true, Component: LearnerDashboardPage },
|
||||||
|
{ path: 'courses', Component: LearnerLiveCoursesPage },
|
||||||
|
{ path: 'courses/:slug', Component: LearnerCourseDetailPage },
|
||||||
|
{ path: 'scorm', Component: LearnerScormCoursesPage },
|
||||||
|
{ path: 'scorm/:id', Component: LearnerScormLaunchPage },
|
||||||
|
{ path: 'live', Component: LearnerLiveSessionsPage },
|
||||||
|
{ path: 'live/:id/sessions/:sessionId', Component: LearnerLiveSessionPage },
|
||||||
|
{ path: 'certificates', Component: LearnerCertificatesPage },
|
||||||
|
{ path: 'community', Component: LearnerCommunityPage },
|
||||||
|
{ path: 'billing', Component: LearnerBillingNotEnabledPage },
|
||||||
|
{ path: 'profile', Component: LearnerProfilePage },
|
||||||
|
{ path: 'notifications', Component: LearnerNotificationsPage },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
9
Frontend/src/app/routes/legacy-routes.tsx
Normal file
9
Frontend/src/app/routes/legacy-routes.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { RouteObject } from 'react-router'
|
||||||
|
|
||||||
|
import { LearnerLiveCoursesPage } from '../pages/LearnerLiveCoursesPage'
|
||||||
|
import { LearnerLiveSessionPage } from '../pages/LearnerLiveSessionPage'
|
||||||
|
|
||||||
|
export const legacyRoutes: RouteObject[] = [
|
||||||
|
{ path: '/learner/live-courses', Component: LearnerLiveCoursesPage },
|
||||||
|
{ path: '/learner/live-courses/:id/sessions/:sessionId', Component: LearnerLiveSessionPage },
|
||||||
|
]
|
||||||
37
Frontend/src/app/routes/public-routes.tsx
Normal file
37
Frontend/src/app/routes/public-routes.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Navigate, type RouteObject } from 'react-router'
|
||||||
|
|
||||||
|
import { currentUser } from '../auth/keycloak'
|
||||||
|
import { BlogIndexPage } from '../pages/BlogIndexPage'
|
||||||
|
import { BlogPostPage } from '../pages/BlogPostPage'
|
||||||
|
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
|
||||||
|
import { HomePage } from '../pages/HomePage'
|
||||||
|
import { LiveCourseDetailPage } from '../pages/LiveCourseDetailPage'
|
||||||
|
import { LiveCoursesIndexPage } from '../pages/LiveCoursesIndexPage'
|
||||||
|
import { PublicFormPage } from '../pages/PublicFormPage'
|
||||||
|
import { ScormPage } from '../pages/ScormPage'
|
||||||
|
import { PlatformAdminPage } from '../pages/PlatformAdminPage'
|
||||||
|
import { PublicCmsPage } from '../pages/PublicCmsPage'
|
||||||
|
|
||||||
|
function RootRoleRedirect() {
|
||||||
|
const roles = (currentUser().roles ?? []).map(role => role.trim().toLowerCase())
|
||||||
|
if (roles.includes('platform_admin') || roles.includes('platform_owner')) return <Navigate to="/platform-admin" replace />
|
||||||
|
if (roles.includes('admin') || roles.includes('instructor')) return <Navigate to="/admin" replace />
|
||||||
|
if (roles.includes('learner')) return <Navigate to="/learn" replace />
|
||||||
|
return <Navigate to="/home" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicRoutes: RouteObject[] = [
|
||||||
|
{ path: '/', Component: RootRoleRedirect },
|
||||||
|
{ path: '/home', Component: HomePage },
|
||||||
|
{ path: '/forms', Component: FormsBuilderPage },
|
||||||
|
{ path: '/forms/:id', Component: PublicFormPage },
|
||||||
|
{ path: '/pages/:slug', Component: PublicCmsPage },
|
||||||
|
{ path: '/scorm', Component: ScormPage },
|
||||||
|
{ path: '/blog', Component: BlogIndexPage },
|
||||||
|
{ path: '/blog/category/:categorySlug', Component: BlogIndexPage },
|
||||||
|
{ path: '/blog/tag/:tagSlug', Component: BlogIndexPage },
|
||||||
|
{ path: '/blog/:slug', Component: BlogPostPage },
|
||||||
|
{ path: '/live-courses', Component: LiveCoursesIndexPage },
|
||||||
|
{ path: '/live-courses/:slug', Component: LiveCourseDetailPage },
|
||||||
|
{ path: '/platform-admin', Component: PlatformAdminPage },
|
||||||
|
]
|
||||||
222
Frontend/src/app/sitemap-menu.ts
Normal file
222
Frontend/src/app/sitemap-menu.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { apiRequest, listResource } from './api/client'
|
||||||
|
|
||||||
|
export type SitemapTargetType = 'page' | 'post' | 'course'
|
||||||
|
|
||||||
|
export type SitemapMenuItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
targetType: SitemapTargetType
|
||||||
|
targetId: string
|
||||||
|
to: string
|
||||||
|
children: SitemapMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SitemapMenu = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
items: SitemapMenuItem[]
|
||||||
|
createdAt?: number
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SitemapSourceOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
slug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SitemapSources = {
|
||||||
|
pages: SitemapSourceOption[]
|
||||||
|
posts: SitemapSourceOption[]
|
||||||
|
courses: SitemapSourceOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(raw: unknown): SitemapMenuItem {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
const children = Array.isArray(source.children) ? source.children.map(normalizeItem) : []
|
||||||
|
|
||||||
|
const targetTypeRaw = asString(source.targetType)
|
||||||
|
const targetType: SitemapTargetType = targetTypeRaw === 'post' || targetTypeRaw === 'course' ? targetTypeRaw : 'page'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: asString(source.id),
|
||||||
|
label: asString(source.label),
|
||||||
|
targetType,
|
||||||
|
targetId: asString(source.targetId),
|
||||||
|
to: asString(source.to),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSitemapMenu(raw: unknown): SitemapMenu {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
const items = Array.isArray(source.items) ? source.items.map(normalizeItem) : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: asString(source.id),
|
||||||
|
name: asString(source.name),
|
||||||
|
description: asString(source.description) || undefined,
|
||||||
|
items,
|
||||||
|
createdAt: asNumber(source.createdAt),
|
||||||
|
updatedAt: asNumber(source.updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSitemapMenus(): Promise<SitemapMenu[]> {
|
||||||
|
const records = await listResource<unknown>('sitemap-menus')
|
||||||
|
return records.map(normalizeSitemapMenu).filter(menu => menu.id && menu.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSitemapMenu(payload: Pick<SitemapMenu, 'name' | 'description' | 'items'>): Promise<SitemapMenu> {
|
||||||
|
const created = await apiRequest<unknown>('/api/sitemap-menus/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
return normalizeSitemapMenu(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSitemapMenu(id: string, payload: Pick<SitemapMenu, 'name' | 'description' | 'items'>): Promise<SitemapMenu> {
|
||||||
|
const updated = await apiRequest<unknown>(`/api/sitemap-menus/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
return normalizeSitemapMenu(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSitemapMenu(id: string): Promise<void> {
|
||||||
|
await apiRequest<void>(`/api/sitemap-menus/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPathForSource(targetType: SitemapTargetType, option: SitemapSourceOption): string {
|
||||||
|
if (targetType === 'page') {
|
||||||
|
return '/admin/builder'
|
||||||
|
}
|
||||||
|
if (targetType === 'post') {
|
||||||
|
return option.slug ? `/blog/${option.slug}` : '/blog'
|
||||||
|
}
|
||||||
|
return option.slug ? `/live-courses/${option.slug}` : '/live-courses'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSitemapSources(): Promise<SitemapSources> {
|
||||||
|
const [pagesRaw, postsRaw, coursesRaw] = await Promise.all([
|
||||||
|
listResource<unknown>('pages'),
|
||||||
|
apiRequest<unknown[]>('/api/blog/posts'),
|
||||||
|
listResource<unknown>('live-courses'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const pages = pagesRaw
|
||||||
|
.map(raw => {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: asString(source.id),
|
||||||
|
label: asString(source.name) || asString(source.slug) || asString(source.id),
|
||||||
|
slug: asString(source.slug) || undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.id && item.label)
|
||||||
|
|
||||||
|
const posts = postsRaw
|
||||||
|
.map(raw => {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: asString(source.id),
|
||||||
|
label: asString(source.title) || asString(source.slug) || asString(source.id),
|
||||||
|
slug: asString(source.slug) || undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.id && item.label)
|
||||||
|
|
||||||
|
const courses = coursesRaw
|
||||||
|
.map(raw => {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: asString(source.id),
|
||||||
|
label: asString(source.title) || asString(source.slug) || asString(source.id),
|
||||||
|
slug: asString(source.slug) || undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.id && item.label)
|
||||||
|
|
||||||
|
return { pages, posts, courses }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuParentOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenParentOptions(items: SitemapMenuItem[], depth = 0): MenuParentOption[] {
|
||||||
|
const options: MenuParentOption[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
options.push({ id: item.id, label: `${' '.repeat(depth * 2)}${item.label}`.trimStart() })
|
||||||
|
options.push(...flattenParentOptions(item.children, depth + 1))
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMenuItem(items: SitemapMenuItem[], parentId: string | null, newItem: SitemapMenuItem): SitemapMenuItem[] {
|
||||||
|
if (!parentId) {
|
||||||
|
return [...items, newItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map(item => {
|
||||||
|
if (item.id === parentId) {
|
||||||
|
return { ...item, children: [...item.children, newItem] }
|
||||||
|
}
|
||||||
|
return { ...item, children: addMenuItem(item.children, parentId, newItem) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMenuItem(items: SitemapMenuItem[], itemId: string): SitemapMenuItem[] {
|
||||||
|
return items
|
||||||
|
.filter(item => item.id !== itemId)
|
||||||
|
.map(item => ({ ...item, children: removeMenuItem(item.children, itemId) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveWithinSiblings(items: SitemapMenuItem[], itemId: string, direction: 'up' | 'down'): SitemapMenuItem[] {
|
||||||
|
const index = items.findIndex(item => item.id === itemId)
|
||||||
|
if (index === -1) {
|
||||||
|
return items.map(item => ({ ...item, children: moveWithinSiblings(item.children, itemId, direction) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = direction === 'up' ? index - 1 : index + 1
|
||||||
|
if (target < 0 || target >= items.length) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...items]
|
||||||
|
const [entry] = next.splice(index, 1)
|
||||||
|
next.splice(target, 0, entry)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveMenuItem(items: SitemapMenuItem[], itemId: string, direction: 'up' | 'down'): SitemapMenuItem[] {
|
||||||
|
return moveWithinSiblings(items, itemId, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMenuItemLabel(items: SitemapMenuItem[], itemId: string, label: string): SitemapMenuItem[] {
|
||||||
|
return items.map(item => {
|
||||||
|
if (item.id === itemId) {
|
||||||
|
return { ...item, label }
|
||||||
|
}
|
||||||
|
return { ...item, children: updateMenuItemLabel(item.children, itemId, label) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHomeNavItems(items: SitemapMenuItem[]): Array<{ id: string; label: string; to: string }> {
|
||||||
|
return items
|
||||||
|
.map(item => ({ id: item.id, label: item.label, to: item.to }))
|
||||||
|
.filter(item => item.id && item.label && item.to.startsWith('/'))
|
||||||
|
}
|
||||||
81
Frontend/src/app/types.ts
Normal file
81
Frontend/src/app/types.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
export type ThemeConfig = {
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
primary: string
|
||||||
|
primaryForeground: string
|
||||||
|
muted: string
|
||||||
|
mutedForeground: string
|
||||||
|
border: string
|
||||||
|
accent: string
|
||||||
|
radius: number
|
||||||
|
fontFamily: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanvasBlock = {
|
||||||
|
instanceId: string
|
||||||
|
blockId: string
|
||||||
|
content: Record<string, string>
|
||||||
|
styles: Record<string, string>
|
||||||
|
slots?: CanvasBlock[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomBlockDef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageOpenGraph = {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
image?: string
|
||||||
|
url?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageRobots = {
|
||||||
|
index?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
noarchive?: boolean
|
||||||
|
nosnippet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageSEO = {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
canonicalUrl?: string
|
||||||
|
openGraph?: PageOpenGraph
|
||||||
|
robots?: PageRobots
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Page = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug?: string
|
||||||
|
status?: 'draft' | 'published'
|
||||||
|
publishedAt?: number
|
||||||
|
seo?: PageSEO
|
||||||
|
blocks: CanvasBlock[]
|
||||||
|
theme: ThemeConfig
|
||||||
|
customBlocks: CustomBlockDef[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataSource = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'rest' | 'static'
|
||||||
|
url: string
|
||||||
|
method: 'GET' | 'POST'
|
||||||
|
headers: string
|
||||||
|
body: string
|
||||||
|
staticData: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataCacheEntry = {
|
||||||
|
data: Record<string, unknown>[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
19
Frontend/src/main.tsx
Normal file
19
Frontend/src/main.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './app/App.tsx'
|
||||||
|
import './styles/index.css'
|
||||||
|
import { initKeycloak } from './app/auth/keycloak'
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
await initKeycloak()
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />)
|
||||||
|
} catch (error) {
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (root) {
|
||||||
|
root.innerHTML = '<div style="padding:24px;font-family:system-ui,sans-serif">Failed to initialize authentication. Please refresh and try again.</div>'
|
||||||
|
}
|
||||||
|
console.error('keycloak init failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap()
|
||||||
0
Frontend/src/styles/fonts.css
Normal file
0
Frontend/src/styles/fonts.css
Normal file
0
Frontend/src/styles/globals.css
Normal file
0
Frontend/src/styles/globals.css
Normal file
3
Frontend/src/styles/index.css
Normal file
3
Frontend/src/styles/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@import './fonts.css';
|
||||||
|
@import './tailwind.css';
|
||||||
|
@import './theme.css';
|
||||||
4
Frontend/src/styles/tailwind.css
Normal file
4
Frontend/src/styles/tailwind.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import 'tailwindcss' source(none);
|
||||||
|
@source '../**/*.{js,ts,jsx,tsx}';
|
||||||
|
|
||||||
|
@import 'tw-animate-css';
|
||||||
275
Frontend/src/styles/theme.css
Normal file
275
Frontend/src/styles/theme.css
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-size: 16px;
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: #030213;
|
||||||
|
--primary-foreground: oklch(1 0 0);
|
||||||
|
--secondary: oklch(0.95 0.0058 264.53);
|
||||||
|
--secondary-foreground: #030213;
|
||||||
|
--muted: #ececf0;
|
||||||
|
--muted-foreground: #717182;
|
||||||
|
--accent: #e9ebef;
|
||||||
|
--accent-foreground: #030213;
|
||||||
|
--destructive: #d4183d;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: rgba(0, 0, 0, 0.1);
|
||||||
|
--input: transparent;
|
||||||
|
--input-background: #f3f3f5;
|
||||||
|
--switch-background: #cbced4;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: #030213;
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-input-background: var(--input-background);
|
||||||
|
--color-switch-background: var(--switch-background);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar,
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
background-color: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb:hover,
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default typography styles for HTML elements (h1-h4, p, label, button, input).
|
||||||
|
* These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: var(--font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid #4f46e5;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.responsive-table {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tbody {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tr {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(228 228 231);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(6.5rem, 36%) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(113 113 122);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table td[data-primary='true'] {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid rgb(244 244 245);
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table td[data-primary='true']::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table td > :where(button, a) {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Frontend/vite.config.ts
Normal file
41
Frontend/vite.config.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
|
||||||
|
function figmaAssetResolver() {
|
||||||
|
return {
|
||||||
|
name: 'figma-asset-resolver',
|
||||||
|
resolveId(id) {
|
||||||
|
if (id.startsWith('figma:asset/')) {
|
||||||
|
const filename = id.replace('figma:asset/', '')
|
||||||
|
return path.resolve(__dirname, 'src/assets', filename)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
figmaAssetResolver(),
|
||||||
|
// The React and Tailwind plugins are both required for Make, even if
|
||||||
|
// Tailwind is not being actively used – do not remove them
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Alias @ to the src directory
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
|
||||||
|
assetsInclude: ['**/*.svg', '**/*.csv'],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user