diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts new file mode 100644 index 0000000..8b33c66 --- /dev/null +++ b/defaults/interfaces.ts @@ -0,0 +1,8 @@ +export interface AppProperties { + name: string; + icon: string; + pages: Record; + adminOnly: string[]; +} + +export type EmptyObject = Record; diff --git a/defaults/makeIndex.ts b/defaults/makeIndex.ts new file mode 100644 index 0000000..4205b03 --- /dev/null +++ b/defaults/makeIndex.ts @@ -0,0 +1,10 @@ +import { EmptyObject } from "$root/defaults/interfaces.ts"; + +export default function makeIndex< + IndexProps = EmptyObject, +>(basePath: string) { + return async function Index(props: IndexProps) { + const index = (await import(`${basePath}/partials/index.tsx`)).Index; + return index(props); + }; +} diff --git a/defaults/makePartials.tsx b/defaults/makePartials.tsx new file mode 100644 index 0000000..786ae68 --- /dev/null +++ b/defaults/makePartials.tsx @@ -0,0 +1,23 @@ +import { JSX } from "preact"; +import { Partial } from "$fresh/runtime.ts"; +import { RouteConfig } from "$fresh/server.ts"; + +export function getConfig(): RouteConfig { + return { + skipAppWrapper: true, + skipInheritedLayouts: true, + }; +} + +// deno-lint-ignore no-explicit-any +export function makePartials( + page: (props: Props) => JSX.Element, +) { + return function WrappedElements(props: Props): JSX.Element { + return ( + + {page(props)} + + ); + }; +} diff --git a/deno.json b/deno.json index 5401579..adc5a39 100644 --- a/deno.json +++ b/deno.json @@ -29,7 +29,7 @@ "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "$std/": "https://deno.land/std@0.216.0/", - "$root/": "./" + "$root/": "./" }, "compilerOptions": { "jsx": "react-jsx", diff --git a/fresh.config.ts b/fresh.config.ts index 783c68c..e6c4033 100644 --- a/fresh.config.ts +++ b/fresh.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ server: { cert: await Deno.readTextFile("certs/cert.pem"), key: await Deno.readTextFile("certs/key.pem"), - port: 443 - } + port: 443, + }, }); diff --git a/readme.md b/readme.md index fe301d2..6c3c91c 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,4 @@ # ✨ PolyMPR ✨ -The ✨ Poly Module de Pilotage des Ressources ✨ is the ultimate tool to handle various HR task in the INFO department. \ No newline at end of file +The ✨ Poly Module de Pilotage des Ressources ✨ is the ultimate tool to handle +various HR task in the INFO department. diff --git a/routes/(_components)/Footer.tsx b/routes/(_components)/Footer.tsx index 014638f..f71930f 100644 --- a/routes/(_components)/Footer.tsx +++ b/routes/(_components)/Footer.tsx @@ -1,11 +1,12 @@ -type FooterProps = Record; +import { EmptyObject } from "$root/defaults/interfaces.ts"; + +type FooterProps = EmptyObject; export default function Footer(_props: FooterProps) { return (

- © 2025 PolyMPR -{" "} - About + © 2025 PolyMPR - About

); diff --git a/routes/(_components)/PartialLink.tsx b/routes/(_components)/PartialLink.tsx index e69de29..1f6e380 100644 --- a/routes/(_components)/PartialLink.tsx +++ b/routes/(_components)/PartialLink.tsx @@ -0,0 +1,13 @@ +interface PartialLinkProps { + link: string; + partial: string; + display: string; +} + +export default function PartialLink(props: PartialLinkProps) { + return ( + + {props.display} + + ); +} diff --git a/routes/(_islands)/AppNavigator.tsx b/routes/(_islands)/AppNavigator.tsx index 9bdc3d2..4618a55 100644 --- a/routes/(_islands)/AppNavigator.tsx +++ b/routes/(_islands)/AppNavigator.tsx @@ -1,9 +1,4 @@ -export interface AppProperties { - name: string; - icon: string; - pages: Record; - adminOnly: string[]; -} +import { AppProperties } from "$root/defaults/interfaces.ts"; type AppNavigatorProps = { apps: Record; diff --git a/routes/(_islands)/Navbar.tsx b/routes/(_islands)/Navbar.tsx index fe48e50..3428f2d 100644 --- a/routes/(_islands)/Navbar.tsx +++ b/routes/(_islands)/Navbar.tsx @@ -1,11 +1,29 @@ +import PartialLink from "$root/routes/(_components)/PartialLink.tsx"; +import { JSX } from "preact/jsx-runtime"; + type NavbarProps = { + currentApp: string; pages: Record; }; export default function Navbar(props: NavbarProps) { + const links: JSX.Element[] = []; + + for (const page in props.pages) { + links.push( + , + ); + } + return ( - <> -

{JSON.stringify(props.pages)}

- + ); } diff --git a/routes/(apps)/_layout.tsx b/routes/(apps)/_layout.tsx index 4bba7e8..7852df6 100644 --- a/routes/(apps)/_layout.tsx +++ b/routes/(apps)/_layout.tsx @@ -1,24 +1,25 @@ import { FreshContext } from "$fresh/server.ts"; import { Partial } from "$fresh/runtime.ts"; import { State } from "$root/routes/_middleware.ts"; +import { AppProperties } from "$root/defaults/interfaces.ts"; import Navbar from "$root/routes/(_islands)/Navbar.tsx"; -import { AppProperties } from "$root/routes/(_islands)/AppNavigator.tsx"; export default async function AppLayout( request: Request, context: FreshContext, ) { - const currentApp = new URL(request.url).pathname; + const pathname = new URL(request.url).pathname; + const currentApp = pathname.split("/")[1]; const properties: AppProperties = (await import( `./${currentApp}/(_props)/props.ts` - )).default; + )).default; return ( - <> - +
+ - +
); } diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 66ea3c8..d1b2803 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -1,4 +1,4 @@ -import { AppProperties } from "$root/routes/(_islands)/AppNavigator.tsx"; +import { AppProperties } from "$root/defaults/interfaces.ts"; const properties: AppProperties = { name: "PolyNotes", @@ -7,9 +7,9 @@ const properties: AppProperties = { index: "Homepage", notes: "Notes", courses: "Courses management", - students: "Students management" + students: "Students management", }, - adminOnly: [ "courses", "students" ] + adminOnly: ["courses", "students"], }; -export default properties; \ No newline at end of file +export default properties; diff --git a/routes/(apps)/notes/index.tsx b/routes/(apps)/notes/index.tsx index 0d94667..1d82f7f 100644 --- a/routes/(apps)/notes/index.tsx +++ b/routes/(apps)/notes/index.tsx @@ -1,9 +1,2 @@ -type ModulesProps = Record; - -export default function Modules(_props: ModulesProps) { - return ( - <> - click - - ); -} \ No newline at end of file +import makeIndex from "$root/defaults/makeIndex.ts"; +export default makeIndex(import.meta.dirname!); diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index e69de29..9d361cf 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -0,0 +1,17 @@ +import { Partial } from "$fresh/runtime.ts"; +import { RouteConfig } from "$fresh/server.ts"; + +type ModulesProps = Record; + +export const config: RouteConfig = { + skipAppWrapper: true, + skipInheritedLayouts: true, +}; + +export default function Modules(_props: ModulesProps) { + return ( + + notes + + ); +} diff --git a/routes/(apps)/notes/partials/(admin)/students.tsx b/routes/(apps)/notes/partials/(admin)/students.tsx index e69de29..9d361cf 100644 --- a/routes/(apps)/notes/partials/(admin)/students.tsx +++ b/routes/(apps)/notes/partials/(admin)/students.tsx @@ -0,0 +1,17 @@ +import { Partial } from "$fresh/runtime.ts"; +import { RouteConfig } from "$fresh/server.ts"; + +type ModulesProps = Record; + +export const config: RouteConfig = { + skipAppWrapper: true, + skipInheritedLayouts: true, +}; + +export default function Modules(_props: ModulesProps) { + return ( + + notes + + ); +} diff --git a/routes/(apps)/notes/partials/index.tsx b/routes/(apps)/notes/partials/index.tsx index e69de29..cd6763c 100644 --- a/routes/(apps)/notes/partials/index.tsx +++ b/routes/(apps)/notes/partials/index.tsx @@ -0,0 +1,10 @@ +import { getConfig, makePartials } from "$root/defaults/makePartials.tsx"; + +type NotesIndexProps = Record; + +export function Index(_props: NotesIndexProps) { + return bip boup; +} + +export const config = getConfig(); +export default makePartials(Index); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index e69de29..9d361cf 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -0,0 +1,17 @@ +import { Partial } from "$fresh/runtime.ts"; +import { RouteConfig } from "$fresh/server.ts"; + +type ModulesProps = Record; + +export const config: RouteConfig = { + skipAppWrapper: true, + skipInheritedLayouts: true, +}; + +export default function Modules(_props: ModulesProps) { + return ( + + notes + + ); +} diff --git a/routes/_app.tsx b/routes/_app.tsx index e009f58..d98cbde 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -16,9 +16,16 @@ export default async function App( PolyMPR - - + + +
diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 11b8aed..7b52700 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,52 +1,52 @@ -import { FreshContext } from "$fresh/server.ts"; -import { getCookies } from "$std/http/cookie.ts"; -import { isJwtValid } from "@popov/jwt"; - -const PUBLIC_ROUTES = [ - "/", - "/login", - "/logout", - "/about", - "/partials/about", - "/contact", -]; - -export interface State { - isAuthenticated: boolean; -} - -function isRoutePublic(route: string) { - return PUBLIC_ROUTES.includes(route) || route.match(/\..+$/); -} - -export const handler = [ - async function checkAuthentication( - request: Request, - context: FreshContext, - ) { - const cookies = getCookies(request.headers); - context.state.isAuthenticated = await isJwtValid( - cookies["sessionToken"] ?? "", - "NEED TO CHANGE THIS KEY FURTHER IN DEV", - ); - - return await context.next(); - }, - async function ensureAuthentication( - request: Request, - context: FreshContext, - ) { - const url = new URL(request.url); - - if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) { - return new Response(null, { - status: 302, - headers: { - Location: "/login", - }, - }); - } - - return await context.next(); - }, -]; +import { FreshContext } from "$fresh/server.ts"; +import { getCookies } from "$std/http/cookie.ts"; +import { isJwtValid } from "@popov/jwt"; + +const PUBLIC_ROUTES = [ + "/", + "/login", + "/logout", + "/about", + "/partials/about", + "/contact", +]; + +export interface State { + isAuthenticated: boolean; +} + +function isRoutePublic(route: string) { + return PUBLIC_ROUTES.includes(route) || route.match(/\..+$/); +} + +export const handler = [ + async function checkAuthentication( + request: Request, + context: FreshContext, + ) { + const cookies = getCookies(request.headers); + context.state.isAuthenticated = await isJwtValid( + cookies["sessionToken"] ?? "", + "NEED TO CHANGE THIS KEY FURTHER IN DEV", + ); + + return await context.next(); + }, + async function ensureAuthentication( + request: Request, + context: FreshContext, + ) { + const url = new URL(request.url); + + if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) { + return new Response(null, { + status: 302, + headers: { + Location: "/login", + }, + }); + } + + return await context.next(); + }, +]; diff --git a/routes/apps.tsx b/routes/apps.tsx index 3a4312f..8c9843d 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -1,5 +1,6 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; -import AppNavigator, { AppProperties } from "$root/routes/(_islands)/AppNavigator.tsx"; +import { AppProperties } from "$root/defaults/interfaces.ts"; +import AppNavigator from "$root/routes/(_islands)/AppNavigator.tsx"; export const handler: Handlers = { async GET(_request, context) { diff --git a/routes/login.tsx b/routes/login.tsx index 8f29244..77a569e 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,114 +1,114 @@ -import { Handlers } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -import { - parse, - type RegularTagNode, - type TextNode, -} from "@melvdouc/xml-parser"; -import { createJwt } from "@popov/jwt"; -import { setCookie } from "$std/http/cookie.ts"; - -const SERVICE = "https://localhost/login"; -const CAS = "https://ident.univ-amu.fr/cas"; - -interface CasTagNode extends RegularTagNode { - children: [TextNode]; -} - -interface CasGroupNode extends RegularTagNode { - children: CasTagNode[]; -} - -interface CasResponse extends RegularTagNode { - children: [TextNode, CasGroupNode]; -} - -function getTag(tag: CasTagNode): [string, string] { - return [ - tag.tagName.replace("cas:", ""), - tag.children[0].value, - ]; -} - -function createUserJWT(casResponse: CasResponse): Promise { - const nodes = casResponse.children[1].children.map(getTag); - const fullUserInfos: Record = {}; - - nodes.forEach(([key, value]) => { - if (fullUserInfos[key] && Array.isArray(fullUserInfos[key])) { - fullUserInfos[key].push(value); - } else if (fullUserInfos[key]) { - fullUserInfos[key] = [fullUserInfos[key], value]; - } else { - fullUserInfos[key] = value; - } - }); - - const now = Math.floor(Date.now() / 1000); - const oneHour = 60 * 60; - - const payload = { - iss: "PolyMPR", - iat: now, - exp: now + oneHour, - aud: "PolyMPR", - user: fullUserInfos, - }; - - const key = "NEED TO CHANGE THIS KEY FURTHER IN DEV"; - return createJwt(payload, key); -} - -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - async GET(request, context) { - const url = new URL(request.url); - const ticket = url.searchParams.get("ticket"); - - if (ticket) { - const response = await fetch( - `${CAS}/serviceValidate?service=${SERVICE}&ticket=${ticket}`, - ); - const body = parse(await response.text()) as [RegularTagNode]; - const casResponse = body[0].children[0] as CasResponse; - - if (casResponse.tagName != "cas:authenticationSuccess") { - return new Response(null, { - status: 302, - headers: { - Location: `${CAS}/login?service=${SERVICE}`, - }, - }); - } - - const headers = new Headers(); - - setCookie(headers, { - name: "sessionToken", - value: await createUserJWT(casResponse), - }); - headers.set("Location", "/apps"); - - return new Response(null, { - status: 302, - headers, - }); - } - - if (context.state.isAuthenticated) { - return new Response(null, { - status: 302, - headers: { - Location: "/apps", - }, - }); - } else { - return new Response(null, { - status: 302, - headers: { - Location: `${CAS}/login?service=${SERVICE}`, - }, - }); - } - }, -}; +import { Handlers } from "$fresh/server.ts"; +import { State } from "$root/routes/_middleware.ts"; +import { + parse, + type RegularTagNode, + type TextNode, +} from "@melvdouc/xml-parser"; +import { createJwt } from "@popov/jwt"; +import { setCookie } from "$std/http/cookie.ts"; + +const SERVICE = "https://localhost/login"; +const CAS = "https://ident.univ-amu.fr/cas"; + +interface CasTagNode extends RegularTagNode { + children: [TextNode]; +} + +interface CasGroupNode extends RegularTagNode { + children: CasTagNode[]; +} + +interface CasResponse extends RegularTagNode { + children: [TextNode, CasGroupNode]; +} + +function getTag(tag: CasTagNode): [string, string] { + return [ + tag.tagName.replace("cas:", ""), + tag.children[0].value, + ]; +} + +function createUserJWT(casResponse: CasResponse): Promise { + const nodes = casResponse.children[1].children.map(getTag); + const fullUserInfos: Record = {}; + + nodes.forEach(([key, value]) => { + if (fullUserInfos[key] && Array.isArray(fullUserInfos[key])) { + fullUserInfos[key].push(value); + } else if (fullUserInfos[key]) { + fullUserInfos[key] = [fullUserInfos[key], value]; + } else { + fullUserInfos[key] = value; + } + }); + + const now = Math.floor(Date.now() / 1000); + const oneHour = 60 * 60; + + const payload = { + iss: "PolyMPR", + iat: now, + exp: now + oneHour, + aud: "PolyMPR", + user: fullUserInfos, + }; + + const key = "NEED TO CHANGE THIS KEY FURTHER IN DEV"; + return createJwt(payload, key); +} + +// deno-lint-ignore no-explicit-any +export const handler: Handlers = { + async GET(request, context) { + const url = new URL(request.url); + const ticket = url.searchParams.get("ticket"); + + if (ticket) { + const response = await fetch( + `${CAS}/serviceValidate?service=${SERVICE}&ticket=${ticket}`, + ); + const body = parse(await response.text()) as [RegularTagNode]; + const casResponse = body[0].children[0] as CasResponse; + + if (casResponse.tagName != "cas:authenticationSuccess") { + return new Response(null, { + status: 302, + headers: { + Location: `${CAS}/login?service=${SERVICE}`, + }, + }); + } + + const headers = new Headers(); + + setCookie(headers, { + name: "sessionToken", + value: await createUserJWT(casResponse), + }); + headers.set("Location", "/apps"); + + return new Response(null, { + status: 302, + headers, + }); + } + + if (context.state.isAuthenticated) { + return new Response(null, { + status: 302, + headers: { + Location: "/apps", + }, + }); + } else { + return new Response(null, { + status: 302, + headers: { + Location: `${CAS}/login?service=${SERVICE}`, + }, + }); + } + }, +}; diff --git a/routes/logout.tsx b/routes/logout.tsx index e3483d6..4481ac3 100644 --- a/routes/logout.tsx +++ b/routes/logout.tsx @@ -1,30 +1,30 @@ -import { Handlers } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -import { deleteCookie } from "$std/http/cookie.ts"; - -const SERVICE = "https://localhost/"; -const CAS = "https://ident.univ-amu.fr/cas"; - -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - GET(_request, context) { - if (context.state.isAuthenticated) { - const headers = new Headers(); - - deleteCookie(headers, "sessionToken", { path: "/" }); - headers.set("Location", `${CAS}/logout?service=${SERVICE}`); - - return new Response(null, { - status: 302, - headers, - }); - } else { - return new Response(null, { - status: 302, - headers: { - Location: "/", - }, - }); - } - }, -}; +import { Handlers } from "$fresh/server.ts"; +import { State } from "$root/routes/_middleware.ts"; +import { deleteCookie } from "$std/http/cookie.ts"; + +const SERVICE = "https://localhost/"; +const CAS = "https://ident.univ-amu.fr/cas"; + +// deno-lint-ignore no-explicit-any +export const handler: Handlers = { + GET(_request, context) { + if (context.state.isAuthenticated) { + const headers = new Headers(); + + deleteCookie(headers, "sessionToken", { path: "/" }); + headers.set("Location", `${CAS}/logout?service=${SERVICE}`); + + return new Response(null, { + status: 302, + headers, + }); + } else { + return new Response(null, { + status: 302, + headers: { + Location: "/", + }, + }); + } + }, +}; diff --git a/static/styles/app.css b/static/styles/app.css new file mode 100644 index 0000000..27096b2 --- /dev/null +++ b/static/styles/app.css @@ -0,0 +1,48 @@ +#app { + margin: 0; + padding: 1em 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 1em; +} + +#app > nav { + display: flex; + flex-direction: column; +} + +#app > nav > a { + padding: 0.25em 0.5em; +} + +#app > nav > a::before { + left: 0; + top: 0; + bottom: 0; + width: 2px; + height: auto; + right: unset; + transform: scaleY(0); + transform-origin: bottom; +} + +#app > nav > a:focus::before, #app > nav > a:hover::before { + transform: scaleY(1); + transform-origin: top; +} + +#app > nav > a[data-current="true"] { + background-color: color-mix( + in srgb, + light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ) 10%, + transparent + ); +} + +#app > nav > a[data-current="true"]::before { + transform: scaleY(1); + transform-origin: top; +} diff --git a/static/styles/main.css b/static/styles/main.css index 1ddc7ef..5a75edf 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -1,87 +1,96 @@ -:root { - color-scheme: dark; - - --dark-background-color: rgb(30, 30, 42); - --dark-background-color-ui: rgb(50, 50, 62); - --dark-foreground: rgb(241, 241, 255); - --dark-foreground-dim: rgb(171, 171, 179); - - --light-background-color: rgb(225, 225, 237); - --light-background-color-ui: rgb(241, 241, 255); - --light-foreground: rgb(30, 30, 42); - --light-foreground-dim: rgb(105, 105, 110); - - --dark-accent-color: rgb(84, 174, 219); - --light-accent-color: rgb(0, 109, 163); - - --loader-size: 0.5em; -} - -* { - box-sizing: border-box; - font-family: "Jetbrains Mono"; -} - -html, body { - margin: 0; - padding: 0; -} - -body { - min-height: 100dvh; - display: grid; - grid-template-rows: auto 1fr auto; - background-color: light-dark(var(--light-background-color), var(--dark-background-color)); - color: light-dark(var(--light-foreground), var(--dark-foreground)); -} - -header { - padding: 0.5em 2em; - display: flex; - justify-content: space-between; - align-items: center; -} - -header > nav { - display: flex; - gap: 1em; -} - -footer { - padding: 0.5em; - display: flex; - justify-content: center; - color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); - font-style: italic; -} - -section { - margin: 0 1.5em; - padding: 0.5em 1.5em; - background-color: light-dark(var(--light-background-color-ui), var(--dark-background-color-ui)); - border-radius: 0.5em; -} - -a { - position: relative; - text-decoration: none; - color: light-dark(var(--light-accent-color), var(--dark-accent-color)); -} - -a::before { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); - transform-origin: right; - transform: scaleX(0); - transition: transform 100ms ease-in-out; -} - -a:focus::before, a:hover::before { - transform-origin: left; - transform: scaleX(1); -} \ No newline at end of file +:root { + color-scheme: light; + + --dark-background-color: rgb(30, 30, 42); + --dark-background-color-ui: rgb(50, 50, 62); + --dark-foreground: rgb(241, 241, 255); + --dark-foreground-dim: rgb(171, 171, 179); + + --light-background-color: rgb(225, 225, 237); + --light-background-color-ui: rgb(241, 241, 255); + --light-foreground: rgb(30, 30, 42); + --light-foreground-dim: rgb(105, 105, 110); + + --dark-accent-color: rgb(84, 174, 219); + --light-accent-color: rgb(0, 109, 163); + + --loader-size: 0.5em; +} + +* { + box-sizing: border-box; + font-family: "Jetbrains Mono"; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + min-height: 100dvh; + display: grid; + grid-template-rows: auto 1fr auto; + background-color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +header { + padding: 0.5em 2em; + display: flex; + justify-content: space-between; + align-items: center; +} + +header > nav { + display: flex; + gap: 1em; +} + +footer { + padding: 0.5em; + display: flex; + justify-content: center; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-style: italic; +} + +section { + margin: 0 1.5em; + padding: 0.5em 1.5em; + background-color: light-dark( + var(--light-background-color-ui), + var(--dark-background-color-ui) + ); + border-radius: 0.5em; +} + +a { + position: relative; + text-decoration: none; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +a::before { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + transform-origin: right; + transform: scaleX(0); + transition: transform 100ms ease-in-out; +} + +a:focus::before, a:hover::before { + transform-origin: left; + transform: scaleX(1); +}