diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md new file mode 100644 index 000000000..14d5bd903 --- /dev/null +++ b/.design-sync/NOTES.md @@ -0,0 +1,177 @@ +# design-sync notes — projectNext → claude.ai/design + +Repo-specific gotchas for future syncs. Read this **and** `config.json` before +re-running. Target project: `Ohma Design System` +(`451c0873-159d-403e-af6c-346418a5e5f9`). + +## Why this repo needs a staging step + +projectNext is a **Next.js app, not a component library**: no library build, no +`dist/`, and every component styles itself with `*.module.scss`, which esbuild +cannot load. `.design-sync/stage-pkg.mjs` mechanically transforms the real source +into a bundlable package under `.design-sync/.cache/pkg/`: + +- compiles each `*.module.scss` → `*.module.css` with `sass` (a custom importer + resolves the `@/styles` alias); esbuild then handles `*.module.css` natively as + CSS modules, so the class-name map survives +- rewrites only import specifiers; component bodies are copied verbatim +- swaps `next/link` → `src/_shims/Link`, `next/navigation` → `src/_shims/navigation` +- emits `index.ts` (the barrel that defines the synced surface), `tsconfig.json`, + `package.json`, and `styles/globals.css` +- copies `public/fonts/**` in and rewrites the app's absolute `url('/fonts/…')` + references to relative ones + +`.design-sync/scope.json` is the **source of truth for which components sync** +(`{Name: {group, src, export}}`). Add or remove entries there, not in config. + +Rebuild command (also in `config.buildCmd`): `node .design-sync/stage-pkg.mjs`. +It emits the `.d.ts` tree itself — it wipes its output dir (`types/` included) +and the converter derives the component list from that tree, so a separate +forgotten `tsc` silently produced a 12-component bundle instead of the full set. + +## Environment + +- **Node is not on `PATH`** on this NixOS host. Use + `export PATH=/nix/store/hwjfj8m2kcsl7kz2xa5yf84jbfh9jssf-nodejs-24.18.1/bin:$PATH` + (or whatever `ls -d /nix/store/*nodejs*/bin` shows). The app itself normally runs + in Docker (`pn-dev`), but the sync runs on the host against `./node_modules`. +- **Playwright chromium is not downloaded.** The system chromium works: + `export DS_CHROMIUM_PATH=/run/current-system/sw/bin/chromium`. Only the + `playwright` npm package is installed in `.ds-sync/` + (`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`). +- Run the converter with `--node-modules ./node_modules --entry + ./.design-sync/.cache/pkg/index.ts`. + +## Decisions taken (and why) + +- **`Date` → exported as `DateDisplay`.** The component's real name is `Date`, but + the converter's `.jsx` stub does `Object.assign(window, { : … })`, which + would clobber the global `Date` constructor on any page that loads it. Renamed in + the barrel only; the source is untouched. +- **`Inter` is vendored from Google Fonts** (`.design-sync/vendor-inter.mjs`, SIL + OFL). `src/styles/_fonts.scss` sets `$primary: 'Inter', sans-serif` — Inter is the + body font for the whole site — but the app never `@font-face`s it and ships no + file, so **production has been falling back to system sans**. Worth fixing in the + app itself. Cache is gitignored, so a fresh clone must re-run the script (needs + network). +- **`OhmaPreviewSurface`** exists only as the outer half of `cfg.provider`. The + card harness hard-codes `body{background:#fff}`, and this DS is dark-only with + near-white `--text`, so without it text and icons render invisible. It never + wraps a real design (those get the dark surface from `styles.css`). +- **`OhmaProviders`** nests the app's real `EditModeProvider`, + `PageTitleProvider` and `PopUpProvider`. Not a reimplementation — it imports them. +- **`CheckboxFieldPresent` is excluded from the card list** (`componentSrcMap: + null`). It renders a deliberately hidden input — internal form plumbing used by + `Checkbox`/`Slider`, never composed directly. Still in the bundle. +- **`MobileNavBar` is not synced**: it transitively imports `StandardImageServer`, + which pulls in the service layer. +- **`TagHeasderItemPopUp` keeps its typo** — that is the real export name in + `HeaderItems/HeaderItemPopUp.tsx`. Renaming would desync the DS from the app. + +## Themes + +The app ships **four** themes in +`src/app/users/[username]/(user-admin)/theme/theme.ts` — `Standard`, `Light`, +`Solarized`, `StjerneInnbygger` — applied at runtime by `applyTheme()`, which +sets 17 custom properties on `document.documentElement`. `ThemeEnabler` (mounted +in `app/layout.tsx`) restores the persisted choice on load. + +`stage-pkg.mjs` reads `theme.ts` at build time (in a child process with +`--experimental-transform-types`, because the file uses an `enum` and Node's +default type-stripping can't handle that) and emits a +`:root[data-theme=""]` block per theme into `styles/globals.css`. The +barrel also re-exports `themes` / `applyTheme` / `subscribeToTheme` / +`getActiveTheme` / `ThemeName`, so a design can switch themes exactly as the app +does. `ThemeName` is excluded from the card list (`componentSrcMap: null`) — it's +an enum, not a component. + +**Do not hand-copy palette values anywhere.** `theme.ts` is the single source of +truth; the CSS is generated from it on every build. + +Verified in a real card (both mechanisms): `[data-theme]` and `applyTheme()` both +re-colour the shipped components. + +**Inconsistency worth fixing in the app:** `globals.scss` and `theme.ts`'s +`Standard` disagree — `globals.scss` has `--text: hsl(0,0%,90%)` and +`--accent-blue: #037FFC`, while `themes.Standard` has `hsl(0,0%,80%)` and +`hsl(210,70%,50%)`. So picking "Standard" in the theme switcher yields slightly +different colours than a fresh page load, which never applied a theme. + +## Component defects found while authoring previews + +These are faithfully reproduced in the cards and documented in `conventions.md`. +They are **app bugs, not sync bugs** — fix them in the repo, then re-sync. + +- ~~`UI/Select.module.scss` styles almost nothing…~~ **Fixed.** Select now uses + `appearance: none` plus the shared field treatment; verified in the built CSS + (`.Select_field` carries `surface-raised` / `ink-strong` / border / radius). +- ~~`UI/FileInput.module.scss` `.black` … dark text on black.~~ **Fixed.** + `.FileInput_black > .trigger .value` now resolves to `--ink-strong`. +- `UI/Slider.module.scss` `secondary` maps the track to `--surface-base`, invisible + against the app surface. Same for `TextInput`'s `secondary` text colour. +- `UI/Checkbox.module.scss`: the `children` branch (`.inputAndChildren`) drops the + custom box styling and falls back to the native checkbox. +- ~~`UI/BorderButton.module.scss` has no `:disabled` rule.~~ Retired — the + outline button tier was removed entirely (component, `borderBtn` mixin and all + ten stylesheet call sites) in favour of the filled `secondaryBtn` tier. If a + future sync sees `BorderButton` reappear in `scope.json`, that is a mistake. +- `Table/SimpleTable.tsx` wraps each `` in a `` when `links` is passed — + an `` containing a `` is invalid HTML and browsers hoist it out, visibly + breaking the rows. The `WithLinks` story was dropped for this reason. +- `SideBarNavItem` / `AdminNav` labels only appear under + `.DesktopSideBar[data-expanded='true']`. That shell is `DesktopSideBar.tsx`, a + server component that can't ship, so `expanded` has no visible effect in a card. + +## Known render warns + +None. The final `package-validate.mjs` run exits 0 with **zero** warnings and +39/39 previews rendering cleanly (40 before `BorderButton` was retired). Any warn +on a future run is new — investigate before recording it here. + +## Preview techniques used + +- Overlay/menu open states are produced by **clicking the real trigger on mount** + (`useEffect` → `querySelector('button').click()`), never by hand-writing panel + markup. `SearchableDropdown` focuses its combobox input instead. +- Popup components carry `cfg.overrides. = {cardMode: "single", + primaryStory: "Opened", viewport: "900x620"}` because the panel is + position-fixed and escapes a grid cell. +- `SimpleTable` and `RadioLarge` use `cardMode: "column"` (they overflowed a + multi-column grid cell). +- `Dropzone` builds real decodable PNGs with a `` at mount, because it + renders `URL.createObjectURL(file)` thumbnails. A truncated/padded PNG shows as + a broken-image box. +- `NavBarTitle`'s title state is produced by mounting a hidden real `PageWrapper`, + which is the only thing that writes `PageTitleContext`. +- Content is real Norwegian Omega material (committee names from + `NavBar/navDef.ts`, realistic event names) — never `foo`/`bar`. **Person names + are placeholders** (`Ola Nordmann` / `olanord`, plus invented Norwegian names); + don't put real members into previews — these cards are published. +- `.prompt.md` embeds examples lifted from `.design-sync/previews/.tsx`, + so editing a preview changes the uploaded doc too, not just the card. + +## Re-sync risks + +- **Node/chromium paths above are host-specific** and will rot on a nix-store + garbage-collect or an OS upgrade. Re-derive them, don't trust them. +- **`scope.json` drifts silently.** If a component listed there is renamed, moved + or gains a service import, `stage-pkg.mjs` fails loudly (good) — but a *new* + presentational component will simply never be synced until someone adds it. + Re-check `src/app/_components/` against `scope.json` on each sync. +- **Inter is fetched from Google Fonts at stage time.** No network → no Inter → + every card silently falls back to system sans. `stage-pkg.mjs` prints + `! Inter not vendored yet`; don't ignore it. +- **The scss `@font-face` strip** in `stage-pkg.mjs` removes `@font-face` blocks + from every `*.module.css` (sass re-emits them into each file via + `@use "ohma"`). If font handling in `src/styles/` changes shape, verify the + canonical copy still reaches `fonts/fonts.css`. +- **Component defects above may get fixed in the app.** When they are, update + `conventions.md`'s "Known rough edges" and re-author the affected previews + (`Select*` especially — they exist only to document a defect right now). +- Previews were verified against **Chromium 151**; native control rendering + (``, `type="color"`) is UA-dependent. +- `_ds_bundle.js` was confirmed **byte-deterministic** across two consecutive + builds from an unchanged staged package. If a future no-change run reports a + changed `bundleSha12`, something in `stage-pkg.mjs`'s emission order has become + input-dependent — chase it rather than shrugging, because it would invalidate + the carried-forward grades that make re-syncs cheap. diff --git a/.design-sync/config.json b/.design-sync/config.json new file mode 100644 index 000000000..c05850a31 --- /dev/null +++ b/.design-sync/config.json @@ -0,0 +1,45 @@ +{ + "projectId": "451c0873-159d-403e-af6c-346418a5e5f9", + "shape": "package", + "pkg": "@ohma/ui", + "globalName": "OhmaUI", + "srcDir": "src", + "buildCmd": "node .design-sync/stage-pkg.mjs", + "readmeHeader": ".design-sync/conventions.md", + "cssEntry": "styles/globals.css", + "extraFonts": ["styles/globals.css"], + "provider": { + "component": "OhmaPreviewSurface", + "inner": { "component": "OhmaProviders" } + }, + "overrides": { + "PopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "AddHeaderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "HelpHeaderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "SettingsHeaderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "UsersHeaderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "TagHeasderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "ArchiveHeaderItemPopUp": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "Menu": { "cardMode": "single", "primaryStory": "Opened", "viewport": "900x620" }, + "SimpleTable": { "cardMode": "column" }, + "RadioLarge": { "cardMode": "column" } + }, + "componentSrcMap": { + "OhmaProviders": null, + "OhmaPreviewSurface": null, + "CheckboxFieldPresent": null, + "ThemeName": null, + "SelectString": "src/inputs/Select.tsx", + "SelectNumber": "src/inputs/Select.tsx", + "DateDisplay": "src/data-display/Date.tsx", + "NavBarItem": "src/navigation/Item.tsx", + "SubPageNavBar": "src/navigation/SubPageNavBar.tsx", + "SubPageNavBarItem": "src/navigation/SubPageNavBar.tsx", + "AddHeaderItemPopUp": "src/overlays/HeaderItemPopUp.tsx", + "HelpHeaderItemPopUp": "src/overlays/HeaderItemPopUp.tsx", + "SettingsHeaderItemPopUp": "src/overlays/HeaderItemPopUp.tsx", + "UsersHeaderItemPopUp": "src/overlays/HeaderItemPopUp.tsx", + "TagHeasderItemPopUp": "src/overlays/HeaderItemPopUp.tsx", + "ArchiveHeaderItemPopUp": "src/overlays/HeaderItemPopUp.tsx" + } +} diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md new file mode 100644 index 000000000..681dd60ac --- /dev/null +++ b/.design-sync/conventions.md @@ -0,0 +1,136 @@ +# Ohma — how to build with this design system + +Ohma is the component library of **Sanctus Omega Broderskab** (projectNext). It +is **fully themed** — four palettes, swapped at runtime — and the UI language is +Norwegian (bokmål). Write labels, placeholders and helper copy in Norwegian. + +## Wrap every tree in `OhmaProviders` + +Several components read app-level React context. Without the provider they throw +or render an empty placeholder: + +- `PopUp` and every `*HeaderItemPopUp` throw `Pop up context needed for popups` +- `PageWrapper` writes the page title; `NavBarTitle` reads it — outside the + provider `NavBarTitle` renders only a blank reserved-space strip +- `AdminNav` reads edit-mode state + +```jsx +const { OhmaProviders, PageWrapper, Button } = window.OhmaUI + + + + + + +``` + +## Styling idiom: CSS custom properties, not utility classes + +**There is no utility-class vocabulary — do not invent one, and do not write +`class="bg-surface-1"`-style names; nothing will resolve.** Component internals +are styled by hashed CSS-module classes you cannot target. Style your own layout +glue with the design tokens below, via inline `style` or your own CSS. + +The tokens are declared on `:root`/`html` by the shipped stylesheet, and **17 of +the 21 are re-declared by every theme** (see Themes below). These 21 are the +complete set: + +| Group | Tokens | +|---|---| +| Surfaces | `--surface-base` (page), `--surface-raised` (cards/panels), `--surface-hover`, `--surface-subtle` | +| Text | `--text`, `--text-muted`, `--text-inv` (on accent fills) | +| Ink | `--ink-hover`, `--ink-strong` | +| Accents | `--accent-blue` (primary), `--accent-red`, `--accent-green`, `--accent-yellow`, `--accent-orange`, `--accent-cyan`, `--accent-magenta`, `--accent-violet` | +| Shape / spacing | `--rounding` (1rem), `--gap` (0.5rem) | +| Depth | `--layer`, `--boxShadow` | + +Idiomatic panel: + +```jsx +
+``` + +**Always paint a `--surface-base` (or `--surface-raised`) background behind your +content**, and always take text colour from `--text` / `--text-muted`. Dropping +content onto an unpainted background is the single most common way to end up with +invisible text here, because the default theme's `--text` is near-white. + +## Themes + +Four themes ship with the design system: **`Standard`** (dark, the default), +**`Light`**, **`Solarized`** (warm light), and **`StjerneInnbygger`** (deep blue). +Each re-declares the same 17 colour tokens — every surface, text, ink and accent +above; `--rounding`, `--gap`, `--text-inv` and `--boxShadow` stay fixed. + +Two equivalent ways to select one: + +```jsx +// Declarative — the stylesheet ships a block per theme. + + +// Runtime, exactly as the app's own theme picker does it: +const { applyTheme, ThemeName, themes } = window.OhmaUI +applyTheme(ThemeName.Solarized) // sets the custom properties on +``` + +`themes` is the raw palette map (`themes.Light['surface-base']` etc.) if you need +to read a value rather than apply one. `subscribeToTheme(fn)` notifies on change; +`getActiveTheme()` reads the persisted choice. + +**This is why the token rule matters.** A component or layout styled with +`var(--surface-raised)` follows all four themes for free. A hardcoded `#1a1a1e` +looks correct in `Standard` and broken in `Light` and `Solarized` — so never +hardcode a colour that a token already names. + +## Type + +`Inter` (300/400/500/700) is the body face and applies by default. `PlayfairDisplay` +(serif) is the display face — `PageWrapper` already uses it for its `h1`; reach for +it only for headings. Both ship with the bundle. Font sizes are fluid `clamp()` +values baked into the components; don't hardcode px type. + +## Component groups + +`actions` (Button) · `inputs` (TextInput, Textarea, NumberInput, +DateInput, ColorInput, FileInput, Checkbox, RadioLarge, Slider, Dropzone, +SelectString, SelectNumber, SearchableDropdown) · `overlays` (PopUp, Dropdown, +EditOverlay, AddHeaderItemPopUp, HelpHeaderItemPopUp, SettingsHeaderItemPopUp, +UsersHeaderItemPopUp, TagHeasderItemPopUp, ArchiveHeaderItemPopUp) · `feedback` +(ProgressBar, SlideInOnView) · `data-display` (SimpleTable, DateDisplay, +CountDown) · `layout` (PageWrapper, SocialIcons) · `navigation` (NavBarItem, Menu, +SideBarNavItem, AdminNav, NavBarTitle, NavTooltip, SubPageNavBar, +SubPageNavBarItem, ReportButton). + +Read each component's `.d.ts` for its exact props and `.prompt.md` +for usage before composing it. The stylesheet of record is `styles.css` and its +imports (`fonts/fonts.css`, `_ds_bundle.css`). + +## Conventions worth copying + +- **Form controls take `name`** and render their own `
`, which browsers hoist out + of the table — avoid it. diff --git a/.design-sync/previews/AddHeaderItemPopUp.tsx b/.design-sync/previews/AddHeaderItemPopUp.tsx new file mode 100644 index 000000000..3a259d452 --- /dev/null +++ b/.design-sync/previews/AddHeaderItemPopUp.tsx @@ -0,0 +1,34 @@ +import { AddHeaderItemPopUp, Button, TextInput } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +/** The panel only opens from a real press on the trigger, so drive that on mount. */ +function OpenOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +const panel = ( +
+

Nytt arrangement

+ + +
+) + +export const Trigger = () => ( + {panel} +) + +export const Opened = () => ( + + {panel} + +) + +export const CustomLabel = () => ( + {panel} +) diff --git a/.design-sync/previews/AdminNav.tsx b/.design-sync/previews/AdminNav.tsx new file mode 100644 index 000000000..4bc70b584 --- /dev/null +++ b/.design-sync/previews/AdminNav.tsx @@ -0,0 +1,35 @@ +import { AdminNav } from '@ohma/ui' +import type { ReactNode } from 'react' + +const Rail = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+) + +/** + * AdminNav renders nothing at all — not even its wrapping nav — unless + * `isAdmin` is true or EditModeContext reports something editable on the page. + * + * Like SideBarNavItem, its icon labels stay collapsed unless an ancestor + * matches `.DesktopSideBar[data-expanded='true']`; that shell is the app's own + * DesktopSideBar server component and is not part of this design system, so + * `expanded` has no visible effect here. + */ +export const Admin = () => ( + +) + +export const NonAdminRendersNothing = () => ( +
+

+ isAdmin=false with nothing editable — AdminNav returns null: +

+ +
+) diff --git a/.design-sync/previews/ArchiveHeaderItemPopUp.tsx b/.design-sync/previews/ArchiveHeaderItemPopUp.tsx new file mode 100644 index 000000000..7227809fd --- /dev/null +++ b/.design-sync/previews/ArchiveHeaderItemPopUp.tsx @@ -0,0 +1,36 @@ +import { ArchiveHeaderItemPopUp, Button } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +/** The panel only opens from a real press on the trigger, so drive that on mount. */ +function OpenOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +const panel = ( +
+

Arkiv

+

+ Arkiverte arrangementer vises ikke i oversikten, men beholdes. +

+ +
+) + +export const Trigger = () => ( + {panel} +) + +export const Opened = () => ( + + {panel} + +) + +export const CustomLabel = () => ( + {panel} +) diff --git a/.design-sync/previews/Button.tsx b/.design-sync/previews/Button.tsx new file mode 100644 index 000000000..0ad859619 --- /dev/null +++ b/.design-sync/previews/Button.tsx @@ -0,0 +1,23 @@ +import { Button } from '@ohma/ui' + +export const Colors = () => ( +
+ + + + +
+) + +export const Disabled = () => ( +
+ + +
+) + +export const Submit = () => ( +
event.preventDefault()}> + +
+) diff --git a/.design-sync/previews/Checkbox.tsx b/.design-sync/previews/Checkbox.tsx new file mode 100644 index 000000000..de5cda72b --- /dev/null +++ b/.design-sync/previews/Checkbox.tsx @@ -0,0 +1,28 @@ +import { Checkbox } from '@ohma/ui' + +export const Default = () => ( + +) + +export const Checked = () => ( + +) + +/** + * `children` become part of the clickable label. Note the box itself loses the + * styled appearance in this mode and falls back to the native checkbox — the + * `.inputAndChildren` branch doesn't carry the custom styling. See NOTES.md. + */ +export const WithChildren = () => ( + + Jeg har matallergier + +) + +export const Group = () => ( +
+ + + +
+) diff --git a/.design-sync/previews/ColorInput.tsx b/.design-sync/previews/ColorInput.tsx new file mode 100644 index 000000000..4c6bf0b73 --- /dev/null +++ b/.design-sync/previews/ColorInput.tsx @@ -0,0 +1,36 @@ +import { ColorInput } from '@ohma/ui' + +/** + * Dressed as a form field like the other inputs: the swatch takes the place of + * the field value, with the selected hex beside it and the label floated above. + */ +export const Default = () => ( +
+ +
+) + +export const FromRGB = () => ( +
+ +
+) + +export const Palette = () => ( +
+ + + +
+) + +export const OnRaisedSurface = () => ( +
+ +
+) diff --git a/.design-sync/previews/CountDown.tsx b/.design-sync/previews/CountDown.tsx new file mode 100644 index 000000000..c09331999 --- /dev/null +++ b/.design-sync/previews/CountDown.tsx @@ -0,0 +1,34 @@ +import { CountDown } from '@ohma/ui' + +// CountDown ticks every 100ms off the real clock, so the reference has to be +// relative to "now" for the cards to show a meaningful remainder. +const inDays = (days: number) => new Date(Date.now() + days * 24 * 60 * 60 * 1000) +const inMinutes = (minutes: number) => new Date(Date.now() + minutes * 60 * 1000) + +export const Days = () => ( +

+ +

+) + +export const HoursAndMinutes = () => ( +

+ +

+) + +export const InHeroPanel = () => ( +
+ Immatrikuleringsballet starter om + + + +
+) diff --git a/.design-sync/previews/DateDisplay.tsx b/.design-sync/previews/DateDisplay.tsx new file mode 100644 index 000000000..eb104dd4d --- /dev/null +++ b/.design-sync/previews/DateDisplay.tsx @@ -0,0 +1,24 @@ +import { DateDisplay } from '@ohma/ui' + +// Fixed dates so the cards are reproducible. DateDisplay renders the UTC string +// on the server pass and switches to the viewer's locale after hydration. +const BALL = new Date('2026-08-15T18:00:00Z') +const DEADLINE = new Date('2026-09-01T23:59:00Z') + +export const WithTime = () => + +export const DateOnly = () => + +export const InSentence = () => ( +

+ Påmeldingen stenger . +

+) + +export const InList = () => ( +
    +
  • Immatrikuleringsball —
  • +
  • Vinsmaking —
  • +
  • Ombul-lansering —
  • +
+) diff --git a/.design-sync/previews/DateInput.tsx b/.design-sync/previews/DateInput.tsx new file mode 100644 index 000000000..c7e1b30fa --- /dev/null +++ b/.design-sync/previews/DateInput.tsx @@ -0,0 +1,24 @@ +import { DateInput } from '@ohma/ui' + +export const Default = () => ( +
+ +
+) + +export const WithTime = () => ( +
+ +
+) + +export const Empty = () => ( +
+ +
+) diff --git a/.design-sync/previews/Dropdown.tsx b/.design-sync/previews/Dropdown.tsx new file mode 100644 index 000000000..467e9f36f --- /dev/null +++ b/.design-sync/previews/Dropdown.tsx @@ -0,0 +1,45 @@ +import { Dropdown } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +const committees = [ + { value: 'vevkom', label: 'Vevkom', key: 'vevkom' }, + { value: 'arrkom', label: 'Arrkom', key: 'arrkom' }, + { value: 'kjellerkom', label: 'Kjellerkom', key: 'kjellerkom' }, + { value: 'redaksjonen', label: 'Redaksjonen', key: 'redaksjonen' }, +] + +/** Dropdown owns its `open` state, so the panel is only reachable by clicking the real trigger. */ +function ClickTriggerOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +export const Closed = () => ( +
+ +
+) + +export const WithSelection = () => ( +
+ +
+) + +export const Opened = () => ( + +
+ +
+
+) + +export const Disabled = () => ( +
+ +
+) diff --git a/.design-sync/previews/Dropzone.tsx b/.design-sync/previews/Dropzone.tsx new file mode 100644 index 000000000..2351394cd --- /dev/null +++ b/.design-sync/previews/Dropzone.tsx @@ -0,0 +1,63 @@ +import { Dropzone } from '@ohma/ui' +import { useEffect, useState } from 'react' + +// Mirrors Dropzone's own FileWithStatus (the barrel exports components, not types). +type FileWithStatus = { file: File, uploadStatus: 'pending' | 'uploading' | 'done' | 'error' } + +const SAMPLES: { name: string, fill: string, status: FileWithStatus['uploadStatus'] }[] = [ + { name: 'immatrikulering-01.png', fill: '#037FFC', status: 'done' }, + { name: 'immatrikulering-02.png', fill: '#5cd17a', status: 'uploading' }, + { name: 'immatrikulering-03.png', fill: '#e6e64d', status: 'pending' }, + { name: 'immatrikulering-04.png', fill: '#eb5757', status: 'error' }, +] + +// Dropzone renders each file as an , so the +// previews need genuinely decodable image bytes — canvas gives us that, at a +// realistic size, without checking a binary fixture into the repo. +// Returns the state pair so the caller can hand both straight to Dropzone — copying +// the generated files into a second useState would only cascade renders. +function usePngFiles() { + const [files, setFiles] = useState([]) + useEffect(() => { + let cancelled = false + Promise.all(SAMPLES.map(({ name, fill, status }) => new Promise(resolve => { + const canvas = document.createElement('canvas') + canvas.width = 320 + canvas.height = 320 + const context = canvas.getContext('2d')! + context.fillStyle = fill + context.fillRect(0, 0, 320, 320) + context.fillStyle = 'rgba(0,0,0,0.35)' + context.fillRect(0, 220, 320, 100) + canvas.toBlob(blob => { + resolve({ file: new File([blob!], name, { type: 'image/png' }), uploadStatus: status }) + }, 'image/png') + }))).then(result => { if (!cancelled) setFiles(result) }) + return () => { cancelled = true } + }, []) + return [files, setFiles] as const +} + +export const Empty = () => { + const [files, setFiles] = useState([]) + return ( + + ) +} + +export const WithFiles = () => { + const [files, setFiles] = usePngFiles() + return ( + + ) +} diff --git a/.design-sync/previews/EditOverlay.tsx b/.design-sync/previews/EditOverlay.tsx new file mode 100644 index 000000000..65e48bd82 --- /dev/null +++ b/.design-sync/previews/EditOverlay.tsx @@ -0,0 +1,33 @@ +import { EditOverlay } from '@ohma/ui' + +/** + * EditOverlay is absolutely positioned and fills its nearest positioned + * ancestor, so it is only meaningful on top of the CMS element it edits. + */ +export const OverCmsParagraph = () => ( +
+
+

Hvad der hender

+

+ Denne teksten kommer fra CMS-et og kan redigeres i redigeringsmodus. +

+
+ +
+) + +export const OverImage = () => ( +
+
+ +
+) diff --git a/.design-sync/previews/FileInput.tsx b/.design-sync/previews/FileInput.tsx new file mode 100644 index 000000000..c77f56e72 --- /dev/null +++ b/.design-sync/previews/FileInput.tsx @@ -0,0 +1,82 @@ +import { FileInput } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +/** + * The chosen file name is component state, reachable only through a real + * selection — so populate the actual and fire the same + * change event the browser would, rather than faking the filled markup. + */ +function SelectFilesOnMount({ names, children }: { names: string[], children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelectorAll('input[type="file"]').forEach(input => { + const transfer = new DataTransfer() + for (const name of names) { + transfer.items.add(new File(['ohma'], name, { type: 'application/octet-stream' })) + } + input.files = transfer.files + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + }, []) + return
{children}
+} + +/** + * Styled like the other form fields: the real input is visually hidden but + * focusable, and the surface doubles as the label that opens the picker. The + * chosen file name takes the place of the field value, so the floating label + * behaves exactly as it does in TextInput. + */ +export const Empty = () => ( +
+ +
+) + +export const WithFile = () => ( + +
+ +
+
+) + +/** `color` styles the chosen file name, so each variant needs a selection. */ +export const Colors = () => ( + +
+ + + + +
+
+) + +export const MultipleFiles = () => ( + +
+ +
+
+) + +export const OnRaisedSurface = () => ( + +
+ +
+
+) + +export const Disabled = () => ( +
+ +
+) diff --git a/.design-sync/previews/HelpHeaderItemPopUp.tsx b/.design-sync/previews/HelpHeaderItemPopUp.tsx new file mode 100644 index 000000000..e78a3235a --- /dev/null +++ b/.design-sync/previews/HelpHeaderItemPopUp.tsx @@ -0,0 +1,35 @@ +import { HelpHeaderItemPopUp } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +/** The panel only opens from a real press on the trigger, so drive that on mount. */ +function OpenOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +const panel = ( +
+

Hjelp

+

+ Trykk på pluss-ikonet for å legge til et nytt arrangement. Endringer lagres først når du trykker Opprett. +

+
+) + +export const Trigger = () => ( + {panel} +) + +export const Opened = () => ( + + {panel} + +) + +export const CustomLabel = () => ( + {panel} +) diff --git a/.design-sync/previews/Menu.tsx b/.design-sync/previews/Menu.tsx new file mode 100644 index 000000000..13b33b226 --- /dev/null +++ b/.design-sync/previews/Menu.tsx @@ -0,0 +1,39 @@ +import { Menu } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import { + faBeer, faBook, faCalendar, faCamera, faComment, faNewspaper, faSuitcase, +} from '@fortawesome/free-solid-svg-icons' +import type { ReactNode } from 'react' + +const items = [ + { name: 'Komitéer', href: '/committees', show: 'all' as const, icon: faBeer }, + { name: 'Hvad der hender', href: '/events', show: 'all' as const, icon: faCalendar }, + { name: 'Ombul', href: '/ombul', show: 'all' as const, icon: faBook }, + { name: 'Nyheter', href: '/news', show: 'all' as const, icon: faNewspaper }, + { name: 'Bilder', href: '/image-collections', show: 'all' as const, icon: faCamera }, + { name: 'Omegaquotes', href: '/omegaquotes', show: 'loggedIn' as const, icon: faComment }, + { name: 'Karriere', href: '/career', show: 'loggedIn' as const, icon: faSuitcase }, +] + +/** Menu owns `isOpen`; the panel is only reachable by pressing the real trigger. */ +function OpenOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +export const MobileTrigger = () => ( + +) + +export const DesktopTrigger = () => ( + +) + +export const Opened = () => ( + + + +) diff --git a/.design-sync/previews/NavBarItem.tsx b/.design-sync/previews/NavBarItem.tsx new file mode 100644 index 000000000..859db058a --- /dev/null +++ b/.design-sync/previews/NavBarItem.tsx @@ -0,0 +1,16 @@ +import { NavBarItem } from '@ohma/ui' + +export const Default = () => + +export const Row = () => ( +
+ + + + +
+) + +export const LongLabel = () => ( + +) diff --git a/.design-sync/previews/NavBarTitle.tsx b/.design-sync/previews/NavBarTitle.tsx new file mode 100644 index 000000000..bbb5b9d20 --- /dev/null +++ b/.design-sync/previews/NavBarTitle.tsx @@ -0,0 +1,39 @@ +import { NavBarTitle, PageWrapper } from '@ohma/ui' +import type { ReactNode } from 'react' + +/** + * NavBarTitle reads the title out of PageTitleContext, which is written by + * PageWrapper (via PageTitleSetter). With no page mounted it deliberately + * renders a fixed-height placeholder instead, to avoid hydration layout shift — + * so both states have to be shown through a real page. + */ +const Bar = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+) + +export const WithPageTitle = () => ( + <> + +
+ +
+ +) + +export const PlaceholderWithNoTitle = () => ( + <> +

+ No page title set — renders a reserved-space placeholder: +

+ + +) diff --git a/.design-sync/previews/NavTooltip.tsx b/.design-sync/previews/NavTooltip.tsx new file mode 100644 index 000000000..6a256e4fd --- /dev/null +++ b/.design-sync/previews/NavTooltip.tsx @@ -0,0 +1,43 @@ +import { NavTooltip } from '@ohma/ui' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCalendar, faCog } from '@fortawesome/free-solid-svg-icons' + +const IconButton = ({ icon }: { icon: typeof faCog }) => ( + +) + +/** + * NavTooltip renders its children bare until after mount (a deliberate + * hydration-safety measure), then wraps them in a Radix tooltip. The tooltip + * itself only appears on hover/focus, which a static card cannot trigger — so + * these cards show the trigger in its resting state. + */ +export const AroundIcon = () => ( + + + +) + +export const SideBarColumn = () => ( +
+ + + + + + +
+) diff --git a/.design-sync/previews/NumberInput.tsx b/.design-sync/previews/NumberInput.tsx new file mode 100644 index 000000000..75e333413 --- /dev/null +++ b/.design-sync/previews/NumberInput.tsx @@ -0,0 +1,25 @@ +import { NumberInput } from '@ohma/ui' + +export const Default = () => ( +
+ +
+) + +export const WithRange = () => ( +
+ +
+) + +export const OnRaisedSurface = () => ( +
+ +
+) + +export const Disabled = () => ( +
+ +
+) diff --git a/.design-sync/previews/PageWrapper.tsx b/.design-sync/previews/PageWrapper.tsx new file mode 100644 index 000000000..78b6478ee --- /dev/null +++ b/.design-sync/previews/PageWrapper.tsx @@ -0,0 +1,37 @@ +import { PageWrapper, Button, SimpleTable, AddHeaderItemPopUp, TextInput } from '@ohma/ui' + +export const Default = () => ( + +

+ Alt som skjer i Omega, samlet på ett sted. +

+
+) + +export const WithHeaderItem = () => ( + +
+

Ny komité

+ + +
+ + } + > + +
+) + +export const HiddenTitle = () => ( + +

+ Tittelen er skjult i innholdet, men settes fortsatt for navigasjonslinjen. +

+
+) diff --git a/.design-sync/previews/PopUp.tsx b/.design-sync/previews/PopUp.tsx new file mode 100644 index 000000000..c46b37b21 --- /dev/null +++ b/.design-sync/previews/PopUp.tsx @@ -0,0 +1,35 @@ +import { PopUp, Button, TextInput } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +/** + * PopUp keeps `isOpen` in its own state and teleports the panel up to + * PopUpProvider, so the open state can only be reached by pressing the trigger. + * Clicking it on mount drives the real component rather than faking the panel. + */ +function OpenOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('button')?.click() + }, []) + return
{children}
+} + +export const Trigger = () => ( + Ny søknad}> +

Innholdet i pop-upen.

+
+) + +export const Opened = () => ( + + Rediger profil}> +
+

Rediger profil

+ + + +
+
+
+) diff --git a/.design-sync/previews/ProgressBar.tsx b/.design-sync/previews/ProgressBar.tsx new file mode 100644 index 000000000..85cbb0e19 --- /dev/null +++ b/.design-sync/previews/ProgressBar.tsx @@ -0,0 +1,38 @@ +import { ProgressBar } from '@ohma/ui' + +export const Steps = () => ( +
+
+

Tom (0)

+ +
+
+

En fjerdedel (0.25)

+ +
+
+

Halvveis (0.5)

+ +
+
+

Fullført (1)

+ +
+
+) + +export const InUploadPanel = () => ( +
+
+ Laster opp bilder… + 62 % +
+ +
+) diff --git a/.design-sync/previews/RadioLarge.tsx b/.design-sync/previews/RadioLarge.tsx new file mode 100644 index 000000000..315507f64 --- /dev/null +++ b/.design-sync/previews/RadioLarge.tsx @@ -0,0 +1,38 @@ +import { RadioLarge } from '@ohma/ui' + +export const Default = () => ( + +) + +export const NumericValues = () => ( + +) + +export const ManyOptions = () => ( + +) diff --git a/.design-sync/previews/ReportButton.tsx b/.design-sync/previews/ReportButton.tsx new file mode 100644 index 000000000..87829793a --- /dev/null +++ b/.design-sync/previews/ReportButton.tsx @@ -0,0 +1,22 @@ +import { ReportButton } from '@ohma/ui' + +/** + * ReportButton is a fixed-purpose link to /report — it takes no props. The + * shield icon inherits its size from the surrounding font-size. + */ +export const Default = () => + +export const InNavBarRow = () => ( +
+ Omega + +
+) diff --git a/.design-sync/previews/SearchableDropdown.tsx b/.design-sync/previews/SearchableDropdown.tsx new file mode 100644 index 000000000..66eab3aff --- /dev/null +++ b/.design-sync/previews/SearchableDropdown.tsx @@ -0,0 +1,40 @@ +import { SearchableDropdown } from '@ohma/ui' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +const members = [ + { value: 'olanord', label: 'Ola Nordmann', key: 'olanord' }, + { value: 'ingrids', label: 'Ingrid Solberg', key: 'ingrids' }, + { value: 'jonash', label: 'Jonas Halvorsen', key: 'jonash' }, + { value: 'marenl', label: 'Maren Lie', key: 'marenl' }, + { value: 'sofiek', label: 'Sofie Kristiansen', key: 'sofiek' }, +] + +/** The panel opens on input focus, so focusing the real field is what shows it. */ +function FocusOnMount({ children }: { children: ReactNode }) { + const ref = useRef(null) + useEffect(() => { + ref.current?.querySelector('input[role="combobox"]')?.focus() + }, []) + return
{children}
+} + +export const Closed = () => ( +
+ +
+) + +export const WithSelection = () => ( +
+ +
+) + +export const Opened = () => ( + +
+ +
+
+) diff --git a/.design-sync/previews/SelectNumber.tsx b/.design-sync/previews/SelectNumber.tsx new file mode 100644 index 000000000..32582a0a0 --- /dev/null +++ b/.design-sync/previews/SelectNumber.tsx @@ -0,0 +1,50 @@ +import { SelectNumber } from '@ohma/ui' + +export const Default = () => ( +
+ +
+) + +export const WithLabels = () => ( +
+ +
+) + +export const OnRaisedSurface = () => ( +
+ +
+) diff --git a/.design-sync/previews/SelectString.tsx b/.design-sync/previews/SelectString.tsx new file mode 100644 index 000000000..67f641fac --- /dev/null +++ b/.design-sync/previews/SelectString.tsx @@ -0,0 +1,65 @@ +import { SelectString } from '@ohma/ui' + +const committees = [ + { value: 'vevkom', label: 'Vevkom', key: 'vevkom' }, + { value: 'arrkom', label: 'Arrkom', key: 'arrkom' }, + { value: 'kjellerkom', label: 'Kjellerkom', key: 'kjellerkom' }, + { value: 'redaksjonen', label: 'Redaksjonen', key: 'redaksjonen' }, +] + +/** + * A native `
diff --git a/src/app/_components/UI/ThemeEnabler.tsx b/src/app/_components/UI/ThemeEnabler.tsx new file mode 100644 index 000000000..9be60e523 --- /dev/null +++ b/src/app/_components/UI/ThemeEnabler.tsx @@ -0,0 +1,15 @@ +'use client' + +import { applyTheme, themes } from '@/app/users/[username]/(user-admin)/theme/theme' +import { useEffect } from 'react' +import type { ThemeName } from '@/app/users/[username]/(user-admin)/theme/theme' + +export default function ThemeEnabler() { + useEffect(() => { + const saved = localStorage.getItem('theme') as ThemeName + if (saved && themes[saved]) { + applyTheme(saved) + } + }, []) + return null +} diff --git a/src/app/_components/User/UserCard.module.scss b/src/app/_components/User/UserCard.module.scss index 1c3192d37..f0004d9cb 100644 --- a/src/app/_components/User/UserCard.module.scss +++ b/src/app/_components/User/UserCard.module.scss @@ -1,17 +1,14 @@ @use '@/styles/ohma'; .UserCard { - @include ohma.round; - @include ohma.boxShadow(''); - &:hover { - @include ohma.boxShadow('hover'); - } + padding: ohma.$gap; + border-radius: ohma.$rounding; text-decoration: none; - margin: ohma.$gap; - margin-bottom: 0; + margin: ohma.$gap ohma.$gap 0; box-sizing: border-box; - @include ohma.layer; color: ohma.$colors-text; + background-color: ohma.$colors-surface-raised; + border: 4px solid var(--flairColor, ohma.$colors-surface-raised); display: flex; align-items: center; diff --git a/src/app/_components/User/UserCard.tsx b/src/app/_components/User/UserCard.tsx index 9c1d8a8e7..fda3edeb7 100644 --- a/src/app/_components/User/UserCard.tsx +++ b/src/app/_components/User/UserCard.tsx @@ -2,6 +2,7 @@ import UserDisplayName from './UserDisplayName' import styles from './UserCard.module.scss' import ProfilePicture from './ProfilePicture' import Link from 'next/link' +import type { CSSProperties } from 'react' import type { Image } from '@/prisma-generated-pn-types' import type { UserFiltered } from '@/services/users/types' @@ -10,23 +11,26 @@ export default function UserCard({ user, className, subText, - asClient, }: { user: UserFiltered & { image: Image }, className?: string, subText?: string, - asClient: boolean }) { + const [topFlair] = [...user.flairs].sort((flairA, flairB) => flairA.rank - flairB.rank) + return
- +
{subText &&

{subText}

}
diff --git a/src/app/_components/User/UserDisplayName.tsx b/src/app/_components/User/UserDisplayName.tsx index 1eea76a4e..7c0c449e0 100644 --- a/src/app/_components/User/UserDisplayName.tsx +++ b/src/app/_components/User/UserDisplayName.tsx @@ -7,17 +7,15 @@ import type { UserFiltered } from '@/services/users/types' export default function UserDisplayName({ user, width, - asClient }: { user: Pick, width: number, - asClient: boolean }) { return
{user.firstname} {user.lastname} {user.flairs.map((flair, index) => ( - + ))}
} diff --git a/src/app/_components/User/UserList/UserList.module.scss b/src/app/_components/User/UserList/UserList.module.scss index b3809d8f0..93464d217 100644 --- a/src/app/_components/User/UserList/UserList.module.scss +++ b/src/app/_components/User/UserList/UserList.module.scss @@ -1,88 +1,78 @@ @use '@/styles/ohma'; -$selectionBtnSize: 25px; - .UserList { height: 100%; + display: flex; + flex-direction: column; + > .filters { - padding-right: 3em; width: 100%; - padding-left: .3em; display: flex; flex-flow: row wrap; + align-items: flex-end; margin-bottom: ohma.$gap; - gap: ohma.$gap; - .name { - display: flex; - flex-direction: column; + gap: calc(2 * #{ohma.$gap}); + + .nameFilter { + margin-top: 0; } + .group { display: flex; flex-direction: column; + gap: ohma.$gap; min-width: 150px; - > * { - display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - select { - width: 100%; - } - } } } - > .list { - height: 100%; - overflow-y: scroll; - border-radius: calc(ohma.$rounding + ohma.$gap); - @include ohma.layer(); - @include ohma.boxShadow(''); + + > .listWrapper { + flex: 1 1 auto; + overflow-y: auto; + } + + .list { + @include ohma.table(); + margin: 0; width: 100%; - padding: 0 ohma.$gap; - padding-top: ohma.$gap; - .row { - display: flex; - justify-content: space-between; - padding: 0 .5em; - > .userRow { - flex: 1 1 100%; - } - > button { - border: none; - width: $selectionBtnSize; - height: $selectionBtnSize; - display: grid; - place-items: center; - border-radius: ohma.$rounding; - margin-right: 1em; - &.selected { - background-color: ohma.$colors-primary; - } - } - } - > .head { + + thead tr { position: sticky; top: 0; - left: 0; - padding: 2*ohma.$gap 2*ohma.$gap; - border-radius: ohma.$rounding; - @include ohma.layer(); - @include ohma.boxShadow(''); + z-index: 1; } - .head, .userRow { - display: grid; - grid-template-columns: 3fr 2fr 1fr 1fr; - > * { - text-align: left; + + .sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; + + &:hover { + color: ohma.$colors-accent-blue; } - &.extraInfo { - grid-template-columns: 1fr 1.3fr 1fr 1fr 1fr 0.5fr; + } + + .sortIcon { + margin-left: calc(0.5 * #{ohma.$gap}); + font-size: 0.85em; + } + + tbody tr.clickable { + cursor: pointer; + } + + button { + border: none; + background: transparent; + width: 25px; + height: 25px; + display: grid; + place-items: center; + border-radius: ohma.$rounding; + cursor: pointer; + + &.selected { + background-color: ohma.$colors-accent-blue; } - } + } } } - -.adjust { - width: calc(100% - $selectionBtnSize - 1em); - transform: translateX(calc($selectionBtnSize + 1em)); -} diff --git a/src/app/_components/User/UserList/UserList.tsx b/src/app/_components/User/UserList/UserList.tsx index 264e1608a..5b412f118 100644 --- a/src/app/_components/User/UserList/UserList.tsx +++ b/src/app/_components/User/UserList/UserList.tsx @@ -1,18 +1,21 @@ 'use client' import styles from './UserList.module.scss' -import { SelectNumberPossibleNULL } from '@/UI/Select' +import Dropdown from '@/components/UI/Dropdown' +import SearchableDropdown from '@/components/UI/SearchableDropdown' +import TextInput from '@/components/UI/TextInput' import { UserPagingContext } from '@/contexts/paging/UserPaging' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' import UserRow from '@/components/User/UserList/UserRow' -import useActionCall from '@/hooks/useActionCall' +import { useGroups } from '@/contexts/ClientData' +import { orderOptions } from '@/lib/groups/groupOptions' import { UsersSelectionContext } from '@/contexts/UsersSelection' import { UserSelectionContext } from '@/contexts/UserSelection' -import { readGroupsForPageFilteringAction } from '@/services/users/actions' import { useContext, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCheck } from '@fortawesome/free-solid-svg-icons' +import { faCheck, faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons' import type { UserPagingReturn } from '@/services/users/types' -import type { ChangeEvent, ReactNode } from 'react' +import type { ChangeEvent, MouseEvent, ReactNode } from 'react' import type { GroupType } from '@/prisma-generated-pn-types' import type { ExpandedGroup } from '@/services/groups/types' @@ -20,6 +23,8 @@ type GroupSelectionType = Exclude type DisableGroupFilters = { [K in GroupSelectionType]?: boolean } +type SortField = 'name' | 'username' + type PropTypes = { className?: string displayForUser?: (user: UserPagingReturn) => ReactNode @@ -33,7 +38,10 @@ function getGroupType(groups: ExpandedGroup[] | null, type: GroupType) { return groups ? groups.filter(group => group.groupType === type) : [] } -function getGroupOptions(groups: ExpandedGroup[] | null, type: GroupType) { +function getGroupOptions( + groups: ExpandedGroup[] | null, + type: GroupType +): { value: number | 'NULL', label: string, key: string }[] { return [ ...getGroupType(groups, type).map(group => ({ value: group.id, @@ -44,22 +52,18 @@ function getGroupOptions(groups: ExpandedGroup[] | null, type: GroupType) { value: 'NULL', label: 'Alle', key: 'NULL' - } as const, + }, ] } -function getOrdereOptions(group: ExpandedGroup) { +function getOrdereOptions(group: ExpandedGroup): { value: number | 'NULL', label: string, key: string }[] { return [ - ...Array.from({ length: group.order - group.firstOrder + 1 }, (_, i) => group.firstOrder + i).map(order => ({ - value: order, - label: order.toString(), - key: order.toString() - })), + ...orderOptions(group), { value: 'NULL', label: 'Alle aktive', key: 'NULL' - }as const, + }, ] } @@ -87,10 +91,12 @@ export default function UserList({ const userPaging = useContext(UserPagingContext) const usersSelection = useContext(UsersSelectionContext) const userSelection = useContext(UserSelectionContext) + const router = useRouter() const groupSelected = !!userPaging?.details.selectedGroup - const { data: groups } = useActionCall(readGroupsForPageFilteringAction) + const groupsResult = useGroups() + const groups = groupsResult.status === 'success' ? groupsResult.groups : null const [groupSelection, setGroupSelection] = useState<{ [T in GroupSelectionType]: { group: ExpandedGroup | null, @@ -132,11 +138,27 @@ export default function UserList({ if (!userPaging) throw new Error('UserPagingContext not found') + const currentSort = userPaging.details.sort const handleChangeName = (e: ChangeEvent) => { userPaging.setDetails({ ...userPaging.details, partOfName: e.target.value }) } + const handleSort = (field: SortField) => { + const direction = currentSort?.field === field && currentSort.direction === 'asc' ? 'desc' : 'asc' + userPaging.setDetails({ ...userPaging.details, sort: { field, direction } }) + } + + const sortIcon = (field: SortField) => { + if (currentSort?.field !== field) return + return ( + + ) + } + const handleGroupSelect = (groupId: number | 'NULL', type: GroupSelectionType) => { if (!groups) return setGroupSelection({ @@ -159,28 +181,36 @@ export default function UserList({ }) } + const stopSelectionClickPropagation = (event: MouseEvent) => { + event.stopPropagation() + } + return (
-
+
{ !disableFilters.name && ( -
- - -
+ ) } { !disableFilters.COMMITTEE && (
- handleGroupSelect(groupId, 'COMMITTEE')} options={getGroupOptions(groups, 'COMMITTEE')} /> { - groupSelection.COMMITTEE.group && handleGroupOrderSelect(order, 'COMMITTEE')} options={getOrdereOptions(groupSelection.COMMITTEE.group)} /> @@ -191,14 +221,16 @@ export default function UserList({ { !disableFilters.CLASS && (
- handleGroupSelect(groupId, 'CLASS')} options={getGroupOptions(groups, 'CLASS')} /> { - groupSelection.CLASS.group && handleGroupOrderSelect(order, 'CLASS')} options={getOrdereOptions(groupSelection.CLASS.group)} /> @@ -209,14 +241,16 @@ export default function UserList({ { !disableFilters.STUDY_PROGRAMME && (
- handleGroupSelect(groupId, 'STUDY_PROGRAMME')} options={getGroupOptions(groups, 'STUDY_PROGRAMME')} /> { - groupSelection.STUDY_PROGRAMME.group && handleGroupOrderSelect(order, 'STUDY_PROGRAMME')} options={getOrdereOptions(groupSelection.STUDY_PROGRAMME.group)} /> @@ -227,14 +261,16 @@ export default function UserList({ { !disableFilters.OMEGA_MEMBERSHIP_GROUP && (
- handleGroupSelect(groupId, 'OMEGA_MEMBERSHIP_GROUP')} options={getGroupOptions(groups, 'OMEGA_MEMBERSHIP_GROUP')} /> { - groupSelection.OMEGA_MEMBERSHIP_GROUP.group && handleGroupOrderSelect(order, 'OMEGA_MEMBERSHIP_GROUP')} options={getOrdereOptions(groupSelection.OMEGA_MEMBERSHIP_GROUP.group)} /> @@ -243,57 +279,73 @@ export default function UserList({ ) }
-
- -

Navn

-

Brukernavn

-

Studie

-

Klasse

- { - groupSelected && ( - <> -

Tittel

-

Admin

- - ) - } -
- - ( - - { usersSelection && - - } - { userSelection && - - } - { - displayForUser && displayForUser(user) - } - + + + + {(usersSelection || userSelection) && } + {displayForUser && } + + + + + { + groupSelected && ( + <> + + + + ) } - user={user} - /> - - )} /> + + + + ( + { + if (!linksToUser) return + router.push(`/users/${user.username}`) + }} + > + { usersSelection && + + } + { userSelection && + + } + { + displayForUser && + } + + + )} /> + +
handleSort('name')}> + Navn {sortIcon('name')} + handleSort('username')}> + Brukernavn {sortIcon('username')} + StudieKlasseTittelAdmin
+ + + + {displayForUser(user)}
-
) } diff --git a/src/app/_components/User/UserList/UserRow.module.scss b/src/app/_components/User/UserList/UserRow.module.scss deleted file mode 100644 index e3aee480e..000000000 --- a/src/app/_components/User/UserList/UserRow.module.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use '@/styles/ohma'; - -.UserRow { - border-bottom: 2px solid ohma.$colors-secondary; - margin-top: ohma.$gap; - padding: calc(ohma.$gap / 2) ohma.$gap; - p { - font-weight: ohma.$fonts-weight-s; - } -} - -.clickable { - cursor: pointer; -} diff --git a/src/app/_components/User/UserList/UserRow.tsx b/src/app/_components/User/UserList/UserRow.tsx index 271b30879..ce43d1d2c 100644 --- a/src/app/_components/User/UserList/UserRow.tsx +++ b/src/app/_components/User/UserList/UserRow.tsx @@ -1,41 +1,24 @@ -import styles from './UserRow.module.scss' import UserDisplayName from '@/components/User/UserDisplayName' -import { useRouter } from 'next/navigation' import type { UserPagingReturn } from '@/services/users/types' type PropTypes = { user: UserPagingReturn - className?: string groupSelected?: boolean, - linksToUser?: boolean } -export default function UserRow({ - user, - className, - groupSelected = false, - linksToUser -}: PropTypes) { - const router = useRouter() +export default function UserRow({ user, groupSelected = false }: PropTypes) { return ( - { - if (!linksToUser) return - router.push(`/users/${user.username}`) - }} - > -

-

{user.username}

-

{user.studyProgramme}

-

{user.class}

+ <> + + {user.username} + {user.studyProgramme} + {user.class} { groupSelected && (<> -

{user.selectedGroupInfo?.title}

-

{user.selectedGroupInfo?.admin ? 'Ja' : 'Nei'}

+ {user.selectedGroupInfo?.title} + {user.selectedGroupInfo?.admin ? 'Ja' : 'Nei'} ) } - -
+ ) } diff --git a/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.module.scss b/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.module.scss new file mode 100644 index 000000000..c8f1c6f0a --- /dev/null +++ b/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.module.scss @@ -0,0 +1,10 @@ +@use '@/styles/ohma'; + +.DoubleLevelVisibilityDescription { + display: flex; + flex-direction: column; + gap: 0.2em; + margin-top: 0.5em; + font-size: ohma.$fonts-s; + opacity: 0.85; +} diff --git a/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.tsx b/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.tsx new file mode 100644 index 000000000..969c751d9 --- /dev/null +++ b/src/app/_components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription.tsx @@ -0,0 +1,25 @@ +'use client' +import styles from './DoubleLevelVisibilityDescription.module.scss' +import { describeMatrix } from '@/auth/visibility/describeVisibility' +import { useGroups } from '@/contexts/ClientData' +import type { DoubleLevelVisibilityMatrix } from '@/services/visibility/types' + +type PropTypes = { + doubleLevelVisibility: DoubleLevelVisibilityMatrix, +} + +/** + * A human readable "who can see / who administrates" description of a DoubleLevelVisibilityMatrix - + * reusable by any service built on `implementDoubleLevelVisibilityOperations`, not just image collections. + */ +export default function DoubleLevelVisibilityDescription({ doubleLevelVisibility }: PropTypes) { + const groupsResult = useGroups() + const groups = groupsResult.status === 'success' ? groupsResult.groups : null + + return ( +

+ Kan se: {describeMatrix(doubleLevelVisibility.regularLevel, groups)} + Kan administrere: {describeMatrix(doubleLevelVisibility.adminLevel, groups)} +

+ ) +} diff --git a/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.module.scss b/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.module.scss new file mode 100644 index 000000000..2397359c3 --- /dev/null +++ b/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.module.scss @@ -0,0 +1,38 @@ +@use '@/styles/ohma'; + +.VisibilityAdmin { + display: flex; + flex-direction: column; + gap: 0.5em; + min-width: 350px; +} + +.summary { + font-style: italic; +} + +.editor { + display: flex; + flex-direction: column; + gap: 0.75em; +} + +.requirement { + display: flex; + flex-direction: column; + gap: 0.5em; + padding: 0.5em; + border: 1px solid; +} + +.requirementHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.condition { + display: flex; + gap: 0.5em; + align-items: flex-end; +} diff --git a/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.tsx b/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.tsx new file mode 100644 index 000000000..a93227a07 --- /dev/null +++ b/src/app/_components/Visibility/VisibilityAdmin/VisibilityAdmin.tsx @@ -0,0 +1,187 @@ +'use client' +import styles from './VisibilityAdmin.module.scss' +import Form from '@/components/Form/Form' +import Button from '@/components/UI/Button' +import { SelectNumber, SelectString } from '@/components/UI/Select' +import { useGroups } from '@/contexts/ClientData' +import { describeMatrix } from '@/auth/visibility/describeVisibility' +import { findGroup, orderOptions } from '@/lib/groups/groupOptions' +import { configureAction } from '@/services/configureAction' +import { useState } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons' +import type { + UpdateVisibilityAction, + VisibilityCondition, + VisibilityMatrix, + VisibilityRequirement +} from '@/services/visibility/types' +import type { VisibilityRequirementGroupType } from '@/prisma-generated-pn-types' + +type PropTypes = { + visibility: VisibilityMatrix, + visibilityId: number, + updateVisibilityAction: UpdateVisibilityAction, +} + +export default function VisibilityAdmin({ visibility, visibilityId, updateVisibilityAction }: PropTypes) { + const groupsResult = useGroups() + const groups = groupsResult.status === 'success' ? groupsResult.groups : null + const [requirements, setRequirements] = useState(visibility.requirements) + + function addRequirement() { + const defaultGroupId = groups?.[0]?.id + if (defaultGroupId === undefined) return + setRequirements(previous => [...previous, { conditions: [{ type: 'ACTIVE', groupId: defaultGroupId }] }]) + } + + function removeRequirement(requirementIndex: number) { + setRequirements(previous => previous.filter((_, index) => index !== requirementIndex)) + } + + function addCondition(requirementIndex: number) { + const defaultGroupId = groups?.[0]?.id + if (defaultGroupId === undefined) return + setRequirements(previous => previous.map((requirement, index) => ( + index === requirementIndex + ? { conditions: [...requirement.conditions, { type: 'ACTIVE', groupId: defaultGroupId }] } + : requirement + ))) + } + + function removeCondition(requirementIndex: number, conditionIndex: number) { + setRequirements(previous => previous.map((requirement, index) => ( + index === requirementIndex + ? { conditions: requirement.conditions.filter((_, index_) => index_ !== conditionIndex) } + : requirement + ))) + } + + function updateCondition(requirementIndex: number, conditionIndex: number, updated: VisibilityCondition) { + setRequirements(previous => previous.map((requirement, index) => ( + index === requirementIndex + ? { + conditions: requirement.conditions.map((condition, index_) => ( + index_ === conditionIndex ? updated : condition + )) + } + : requirement + ))) + } + + function handleConditionGroupChange( + requirementIndex: number, + conditionIndex: number, + condition: VisibilityCondition, + groupId: number + ) { + const group = findGroup(groups, groupId) + updateCondition(requirementIndex, conditionIndex, condition.type === 'ORDER' + ? { type: 'ORDER', groupId, order: group?.order ?? condition.order } + : { type: 'ACTIVE', groupId }) + } + + function handleConditionTypeChange( + requirementIndex: number, + conditionIndex: number, + condition: VisibilityCondition, + type: VisibilityRequirementGroupType + ) { + const group = findGroup(groups, condition.groupId) + updateCondition(requirementIndex, conditionIndex, type === 'ORDER' + ? { type: 'ORDER', groupId: condition.groupId, order: group?.order ?? 0 } + : { type: 'ACTIVE', groupId: condition.groupId }) + } + + const saveVisibility = configureAction(updateVisibilityAction, { params: { visibilityId } }) + + async function handleSave() { + return saveVisibility({ data: { requirements } }) + } + + const groupOptions = (groups ?? []).map(group => ({ value: group.id, label: group.name, key: group.id.toString() })) + + return ( +
+

{describeMatrix({ requirements }, groups)}

+ { + groups === null ? ( +

Laster grupper...

+ ) : ( +
+ { + requirements.map((requirement, requirementIndex) => ( +
+
+ Krav {requirementIndex + 1} (ett av følgende): + +
+ { + requirement.conditions.map((condition, conditionIndex) => ( +
+ handleConditionGroupChange( + requirementIndex, conditionIndex, condition, groupId + )} + /> + handleConditionTypeChange( + requirementIndex, + conditionIndex, + condition, + type as VisibilityRequirementGroupType + )} + /> + { + condition.type === 'ORDER' && ( + updateCondition( + requirementIndex, + conditionIndex, + { type: 'ORDER', groupId: condition.groupId, order } + )} + /> + ) + } + +
+ )) + } + +
+ )) + } + +
+
+ ) + } +
+ ) +} diff --git a/src/app/_components/VisiblityAdmin/VisibilityAdmin.module.scss b/src/app/_components/VisiblityAdmin/VisibilityAdmin.module.scss deleted file mode 100644 index f5daa86a7..000000000 --- a/src/app/_components/VisiblityAdmin/VisibilityAdmin.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use '@/styles/ohma'; - -.VisibilityAdmin { - -} \ No newline at end of file diff --git a/src/app/_components/VisiblityAdmin/VisibilityAdmin.tsx b/src/app/_components/VisiblityAdmin/VisibilityAdmin.tsx deleted file mode 100644 index 2ef4479c7..000000000 --- a/src/app/_components/VisiblityAdmin/VisibilityAdmin.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client' -import styles from './VisibilityAdmin.module.scss' -import type { VisibilityMatrix } from '@/services/visibility/types' - - -type PropTypes = { - visibility: VisibilityMatrix -} - -export default function VisibilityAdmin({ visibility }: PropTypes) { - console.log(visibility) - return ( -
-

Synelighet!

-
- ) -} diff --git a/src/app/_components/YouTube/YouTube.module.scss b/src/app/_components/YouTube/YouTube.module.scss index ef096d6fb..e98ca26bb 100644 --- a/src/app/_components/YouTube/YouTube.module.scss +++ b/src/app/_components/YouTube/YouTube.module.scss @@ -14,5 +14,4 @@ border: none; border-radius: ohma.$rounding; background-color: white; - box-shadow: 10px 10px 15px ohma.$colors-gray-700; } diff --git a/src/app/admin/BackButton.module.scss b/src/app/admin/BackButton.module.scss index 19297c6a0..6ac96d447 100644 --- a/src/app/admin/BackButton.module.scss +++ b/src/app/admin/BackButton.module.scss @@ -5,7 +5,7 @@ $iconSize: 30px; .BackButton { > .icon { margin: ohma.$gap; - color: ohma.$colors-gray-600; + color: ohma.$colors-text-muted; width: $iconSize; height: $iconSize; } diff --git a/src/app/admin/BackButton.tsx b/src/app/admin/BackButton.tsx index 167a195df..28bceab5d 100644 --- a/src/app/admin/BackButton.tsx +++ b/src/app/admin/BackButton.tsx @@ -17,7 +17,7 @@ export default function BackButton({ className }: PropTypes) { const href = `/${pathname?.split('/').slice(1, -1).join('/')}` return ( - + ) diff --git a/src/app/admin/SlideSidebar.module.scss b/src/app/admin/SlideSidebar.module.scss index 0b8ebf7f9..7ddbea3d8 100644 --- a/src/app/admin/SlideSidebar.module.scss +++ b/src/app/admin/SlideSidebar.module.scss @@ -1,97 +1,124 @@ @use '@/styles/ohma'; +$mobileToggleBarHeight: 56px; +$mobileToggleButtonSize: 44px; + .SlideSidebar { - @include ohma.layer; - margin: .5em; - padding: 2em; + padding: ohma.$gap; + min-width: 250px; border-radius: ohma.$rounding; - max-height: calc(100dvh - 74px); + max-height: calc(100dvh - #{ohma.$nav-height} - 26px); overflow-y: scroll; - @include ohma.screenMobile { - margin: 8px; - z-index: 3; - height: 40dvh; - width: calc(100% - 16px); - position: fixed; - top: 0; + .toggleButton { + display: none; + } + + .backdrop { + display: none; } - .toggle { - position: absolute; - right: -.6em; - top: min(50%, 50vh); - transform: translateY(-50%); - width: 3em; - height: 3em; - border-radius: 50%; - background-color: ohma.$colors-gray-500; - border: none; - svg { + @include ohma.screenMobile { + padding: 0; + margin: 0; + min-width: 0; + max-height: none; + overflow: visible; + + .toggleButton { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + top: calc(2 * #{ohma.$gap}); + right: calc(3 * #{ohma.$gap}); + z-index: 6; + width: $mobileToggleButtonSize; + height: $mobileToggleButtonSize; + border: none; + border-radius: 50%; + background: ohma.$colors-surface-raised; color: ohma.$colors-text; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + cursor: pointer; } - @include ohma.screenMobile { - right: 8px; - top: 28px; + + .backdrop { + position: fixed; + inset: 0; + z-index: 4; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: opacity ohma.$transitionTime ease, visibility ohma.$transitionTime ease; } - } - &.closed { .sidebar { - width: ohma.$gap; - } - @include ohma.screenMobile { - height: 7 * ohma.$gap; + position: fixed; + top: calc(#{ohma.$nav-height} + 2*#{ohma.$gap}); + left: 0; + width: min(85vw, 320px); + height: calc(100dvh - 2*#{ohma.$nav-height} - 4*#{ohma.$gap})!important; + margin: 0; + padding: ohma.$gap; + background: ohma.$colors-surface-base; + border-radius: 0 ohma.$rounding ohma.$rounding 0; + z-index: 5; + transform: translateX(-100%); + transition: transform ohma.$transitionTime ease; } - .toggle { - > svg { - transform: rotate(180deg); + + &.open { + .backdrop { + opacity: 1; + visibility: visible; + } + .sidebar { + transform: translateX(0); } } } - //Closing - &.closed .sidebar { - transition: width .3s .1s, opacity .1s; - opacity: 0; - } - - //Opening - &:not(.closed) .sidebar { - transition: width .3s, opacity .3s .3s; - opacity: 1; - } - .sidebar { - margin-top: 1em; display: flex; flex-direction: column; + gap: ohma.$gap; overflow-y: auto; height: 100%; color: ohma.$colors-text; - a { - color: ohma.$colors-text-muted; - text-decoration: none; - padding: .5em; - border-radius: .5em; - margin: .5em 0; - transition: background-color .3s; - &:hover, &.active { - @include ohma.layer; - } - } h3 { - margin-top: 1em; + display: flex; + align-items: center; + gap: ohma.$gap; + margin: calc(#{ohma.$gap} * 2) 0 0; + padding: calc(#{ohma.$gap} * 2); + border-radius: calc(#{ohma.$rounding} - #{ohma.$gap}); + background: ohma.$colors-surface-raised; + font-size: ohma.$fonts-l; + font-weight: ohma.$fonts-weight-l; + color: ohma.$colors-ink-strong; + + &:first-child { + margin-top: 0; + } + svg { - margin-right: .2em; + font-size: 0.8em; } } - } - .backButton { - position: absolute; - top: .5em; - left: .5em; + a { + color: ohma.$colors-text; + text-decoration: none; + padding: calc(#{ohma.$gap} * 1.5) calc(#{ohma.$gap} * 2); + margin-left: calc(#{ohma.$gap} * 3); + border-radius: calc(#{ohma.$rounding} - #{ohma.$gap}); + background: ohma.$colors-surface-raised; + transition: background-color ohma.$transitionTime; + &:hover, &.active { + background: ohma.$colors-surface-hover; + } + } } } diff --git a/src/app/admin/SlideSidebar.tsx b/src/app/admin/SlideSidebar.tsx index 09e6d8404..3251206c7 100644 --- a/src/app/admin/SlideSidebar.tsx +++ b/src/app/admin/SlideSidebar.tsx @@ -1,8 +1,9 @@ 'use client' import styles from './SlideSidebar.module.scss' -import BackButton from './BackButton' import useOnNavigation from '@/hooks/useOnNavigation' -import { Fragment, useRef, useState } from 'react' +import useClickOutsideRef from '@/hooks/useClickOutsideRef' +import useKeyPress from '@/hooks/useKeyPress' +import { Fragment, useState } from 'react' import Link from 'next/link' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { @@ -12,13 +13,14 @@ import { faNewspaper, faUser, faUserGroup, - faArrowLeft, faPaperPlane, faSchool, faDotCircle, faHouse, faShop, faListDots, + faBars, + faXmark, } from '@fortawesome/free-solid-svg-icons' import type { IconDefinition } from '@fortawesome/free-solid-svg-icons' @@ -227,6 +229,10 @@ const navigations = [ title: 'Flairs', href: '/admin/flairs' }, + { + title: 'Komponenter', + href: '/admin/component-test' + }, ] } ] satisfies { @@ -250,56 +256,48 @@ type PropTypes = { * @returns */ export default function SlideSidebar({ currentPath }: PropTypes) { - const [open, setOpen] = useState(true) - const previousPath = useRef(currentPath) + const [open, setOpen] = useState(currentPath === 'admin') - useOnNavigation(() => { - if (previousPath.current === 'admin' && currentPath !== 'admin') { - setOpen(false) - } - if (currentPath === 'admin') { - setOpen(true) - } - previousPath.current = currentPath - }) + useOnNavigation(() => setOpen(currentPath === 'admin')) - const handleToggle = () => { - setOpen(!open) - } + const sidebarRef = useClickOutsideRef(() => setOpen(false)) + useKeyPress('Escape', () => setOpen(false)) return
- - { - !(currentPath === 'admin' && open) && ( - - ) - } - - - +
+
+ + +
} diff --git a/src/app/admin/admission/[admission]/page.tsx b/src/app/admin/admission/[admission]/page.tsx index 04cf2c62c..a4532459e 100644 --- a/src/app/admin/admission/[admission]/page.tsx +++ b/src/app/admin/admission/[admission]/page.tsx @@ -1,9 +1,10 @@ import RegisterAdmissiontrial from './registration' import { admissionDisplayNames, allAdmissions } from '@/services/admission/constants' import PageWrapper from '@/components/PageWrapper/PageWrapper' -import { readOmegaJWTPublicKey } from '@/services/omegaid/actions' -import { notFound } from 'next/navigation' +import { readOmegaJWTPublicKeyAction } from '@/services/omegaid/actions' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { type Admission as AdmissionType } from '@/prisma-generated-pn-types' +import { notFound } from 'next/navigation' type PropTypes = { params: Promise<{ @@ -18,7 +19,7 @@ export default async function AdmissionTrials({ params }: PropTypes) { const admission = (await params).admission - const publicKey = await readOmegaJWTPublicKey() + const publicKey = unwrapActionReturn(await readOmegaJWTPublicKeyAction()) return .admin { margin-top: 1em; padding-top: 1em; - border-top: .3em solid ohma.$colors-gray-300; + border-top: .3em solid ohma.$colors-surface-subtle; } } \ No newline at end of file diff --git a/src/app/admin/api-keys/page.module.scss b/src/app/admin/api-keys/page.module.scss index 6612a79b7..be1d1480e 100644 --- a/src/app/admin/api-keys/page.module.scss +++ b/src/app/admin/api-keys/page.module.scss @@ -17,11 +17,11 @@ } .activated { - background-color: color-mix(in srgb, ohma.$colors-green, white 50%); - color: ohma.$colors-green; + background-color: color-mix(in srgb, ohma.$colors-accent-green, white 50%); + color: ohma.$colors-accent-green; } .deactivated { - background-color: color-mix(in srgb, ohma.$colors-red, white 50%); - color: ohma.$colors-red; + background-color: color-mix(in srgb, ohma.$colors-accent-red, white 50%); + color: ohma.$colors-accent-red; } \ No newline at end of file diff --git a/src/app/admin/cabin-periods/page.module.scss b/src/app/admin/cabin-periods/page.module.scss index 9b2614128..6879938bc 100644 --- a/src/app/admin/cabin-periods/page.module.scss +++ b/src/app/admin/cabin-periods/page.module.scss @@ -1,7 +1,7 @@ @use "@/styles/ohma"; .button { - @include ohma.btn(ohma.$colors-primary); + @include ohma.btn(ohma.$colors-accent-blue); } .table { diff --git a/src/app/admin/committees/CreateCommitteeForm.tsx b/src/app/admin/committees/CreateCommitteeForm.tsx index 339653ecc..7148e420f 100644 --- a/src/app/admin/committees/CreateCommitteeForm.tsx +++ b/src/app/admin/committees/CreateCommitteeForm.tsx @@ -1,35 +1,25 @@ -'use client' import styles from './CreateCommitteeForm.module.scss' import Form from '@/components/Form/Form' import TextInput from '@/components/UI/TextInput' +import FileInput from '@/components/UI/FileInput' +import LicenseChooser from '@/components/LicenseChooser/LicenseChooser' import { createCommitteeAction } from '@/services/groups/committees/actions' -import { ImageSelectionContext } from '@/contexts/ImageSelection' -import { useContext } from 'react' /** - * WARNING: The component expects to be rendered inside a ImageSelectionProvider, so the form can - * be submitted with a committee logo. - * A component to create a committee - * @param defaultImage - The default image to use as a committee logo if no logo is selected - * @returns committee form JSX + * A form to create a committee. The logo fields are optional - if left empty the committee falls + * back to the shared default committee logo, which can be replaced later from the committee's own + * admin page. */ export default function CreateCommitteeForm() { - const imageSelection = useContext(ImageSelectionContext) - if (!imageSelection) throw new Error('No context') - - const createCommittee = (data: FormData) => { - if (imageSelection.selectedImage) { - data.append('logoImageId', imageSelection.selectedImage.id.toString()) - } - - return createCommitteeAction(data) - } - return (
- - - + + + + + + +
) diff --git a/src/app/admin/committees/page.module.scss b/src/app/admin/committees/page.module.scss index 9e2971ebc..24d15f282 100644 --- a/src/app/admin/committees/page.module.scss +++ b/src/app/admin/committees/page.module.scss @@ -1,18 +1,5 @@ -@use '@/styles/ohma'; - .wrapper { display: flex; flex-flow: row wrap; gap: 1em; - > .imgSelection { - min-width: clamp(50vw, 700px, 90vw); - min-height: 50vh; - background-color: ohma.$colors-gray-500; - border-radius: ohma.$rounding; - padding: ohma.$rounding; - } - > .form { - min-width: 250px; - - } -} \ No newline at end of file +} diff --git a/src/app/admin/committees/page.tsx b/src/app/admin/committees/page.tsx index 748d9e3c5..0f30cc85d 100644 --- a/src/app/admin/committees/page.tsx +++ b/src/app/admin/committees/page.tsx @@ -1,52 +1,13 @@ import styles from './page.module.scss' import CreateCommitteeForm from './CreateCommitteeForm' -import ImageSelectionProvider from '@/contexts/ImageSelection' -import ImageList from '@/components/Image/ImageList/ImageList' -import { ImagePagingProvider } from '@/contexts/paging/ImagePaging' -import { readSpecialImageCollectionAction } from '@/services/images/collections/actions' -import PopUpProvider from '@/contexts/PopUp' -import { readSpecialImageAction } from '@/services/images/actions' -import type { PageSizeImage } from '@/contexts/paging/ImagePaging' - -export default async function adminCommittee() { - const committeeLogoCollectionRes = await readSpecialImageCollectionAction('COMMITTEELOGOS') - if (!committeeLogoCollectionRes.success) throw new Error('Kunne ikke finne komitelogoer') - const { id: collectionId } = committeeLogoCollectionRes.data - - const defaultCommitteeLogoRes = await readSpecialImageAction.bind( - null, { params: { special: 'DAFAULT_COMMITTEE_LOGO' } } - )() - if (!defaultCommitteeLogoRes.success) throw new Error('Kunne ikke finne standard komitelogo') - const defaultCommitteeLogo = defaultCommitteeLogoRes.data - - const pageSize: PageSizeImage = 30 +import PageWrapper from '@/components/PageWrapper/PageWrapper' +export default function AdminCommittee() { return ( - - - -
-
- -
-
- -
-
-
-
-
+ +
+ +
+
) } diff --git a/src/app/admin/component-test/page.module.scss b/src/app/admin/component-test/page.module.scss new file mode 100644 index 000000000..3db90f074 --- /dev/null +++ b/src/app/admin/component-test/page.module.scss @@ -0,0 +1,38 @@ +@use '@/styles/ohma'; + +.wrapper { + padding: calc(2 * #{ohma.$gap}); + display: flex; + flex-direction: column; + gap: calc(2 * #{ohma.$gap}); + + > p { + color: ohma.$colors-text-muted; + } +} + +.section { + display: flex; + flex-direction: column; + gap: ohma.$gap; + padding-bottom: calc(2 * #{ohma.$gap}); + border-bottom: 1px solid ohma.$colors-surface-subtle; + + &:last-child { + border-bottom: none; + } +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: calc(2 * #{ohma.$gap}); +} + +.column { + display: flex; + flex-direction: column; + gap: ohma.$gap; + max-width: calc(60 * #{ohma.$gap}); +} diff --git a/src/app/admin/component-test/page.tsx b/src/app/admin/component-test/page.tsx new file mode 100644 index 000000000..0aa631db2 --- /dev/null +++ b/src/app/admin/component-test/page.tsx @@ -0,0 +1,152 @@ +'use client' +import styles from './page.module.scss' +import Button from '@/components/UI/Button' +import SubmitButton from '@/components/UI/SubmitButton' +import TextInput from '@/components/UI/TextInput' +import Textarea from '@/components/UI/Textarea' +import Checkbox from '@/components/UI/Checkbox' +import DateInput from '@/components/UI/DateInput' +import Slider from '@/components/UI/Slider' +import FileInput from '@/components/UI/FileInput' +import Dropdown from '@/components/UI/Dropdown' +import SearchableDropdown from '@/components/UI/SearchableDropdown' +import ColorInput from '@/components/UI/ColorInput' +import { SelectString, SelectNumber } from '@/components/UI/Select' +import ProgressBar from '@/components/ProgressBar/ProgressBar' + +const textInputColors = ['primary', 'secondary', 'red', 'black', 'white'] as const +const sliderColors = ['primary', 'secondary', 'red', 'black', 'white'] as const +const fileInputColors = ['primary', 'secondary', 'red', 'black', 'white'] as const +const selectColors = ['primary', 'secondary', 'red', 'black', 'white'] as const + +const dropdownOptions = [ + { value: 'ntnu', label: 'NTNU' }, + { value: 'ntb', label: 'NTB' }, + { value: 'komite', label: 'Komité' }, + { value: 'styret', label: 'Styret' }, +] + +const selectOptions = dropdownOptions.map(option => ({ ...option, key: option.value })) + +const yearOptions = [2020, 2021, 2022, 2023].map(year => ({ value: year, key: String(year) })) + +export default function ComponentTest() { + return ( +
+

Komponenter

+

Et lite utvalg av komponenter som finnes på veven.

+ +
+

Buttons

+
+ + + + + +
+
+ Submit + Success +
+
+ +
+

Text inputs

+
+ {textInputColors.map(color => ( + + ))} +
+
+