feat: update UI

This commit is contained in:
2026-08-24 08:07:23 +07:00
parent d7e0d81462
commit 8fe5e44f52
41 changed files with 3117 additions and 993 deletions
+76
View File
@@ -0,0 +1,76 @@
# CLAUDE.md
Vue 3 + TypeScript SPA — frontend of MWS (My Workspace), an internal tool for small teams to manage projects, documents, and tasks. Talks to a .NET 10 backend over REST + JWT.
## Commands
```bash
npm install # install deps
npm run dev # vite dev server on :5173, calls backend at http://localhost:5000
npm run build # vue-tsc typecheck + vite build → dist/
npm run preview # serve built dist locally
```
No test runner, no linter configured. `vue-tsc -b` during `build` is the only static check.
## Environment
- `VITE_API_BASE_URL` — backend base URL (default `http://localhost:5000`). Set in `.env.local` for dev, or pass `--build-arg VITE_API_BASE_URL=...` to Docker.
- Backend dev port is **5000**, not the production 8080. The `api.ts` interceptor reads `localStorage.mws_token` and attaches `Authorization: Bearer …`; 401s clear storage and redirect to `/login`.
## Layout & routing
Two nested layouts (`src/layouts/`):
- `MainLayout` — app shell with sidebar + topbar. Wraps all authenticated routes. Hosts the global `Toast` and `ConfirmDialog` (in `App.vue`).
- `ProjectLayout` — header + tabs (Overview / Documents / Tasks / Members). Loads the project by `:id` route param on mount and renders `<router-view />` for the tab content.
Routes (`src/router/index.ts`) guard everything except `/login` via `meta.public`. Auth users hitting `/login` redirect to `/projects`. Unknown paths redirect to `/projects`.
Domain areas (each is a folder under `views/`):
- `auth/``LoginView.vue`
- `projects/` — list, overview, members
- `documents/` — tree + editor
- `tasks/` — list, kanban board, detail dialog
## Service layer pattern
Three files in `src/services/`:
- `api.ts` — shared axios instance + interceptors + `errorMessage(error)` helper for surfacing backend `{ message }` errors.
- `backend.ts` — auth, projects, members, users (small subset).
- `modules.ts` — documents + tasks (split here to keep files scannable).
Every API call is a thin typed wrapper around `api.get/post/put/delete`. Views call these directly — there is no separate data layer / composable cache. New endpoints → add a typed function to `backend.ts` or `modules.ts` and a matching interface in `src/types/index.ts`.
## State
Only one Pinia store: `src/stores/auth.ts`. Token + user object, persisted to `localStorage` under `mws_token` / `mws_user`. Everything else (projects, documents, tasks) is fetched per-view — no client cache. Add a new store only if cross-view shared state actually appears.
## Auto-imports
`vite.config.ts` auto-imports:
- Vue / vue-router / pinia globals
- PrimeVue composables: `useToast`, `useConfirm`, `useDialog` (call them without imports in components)
- PrimeVue components via `@primevue/auto-import-resolver` (no need to import `<Button>`, `<DataTable>`, etc. — `Components()` resolves them)
Generated types live in `src/auto-imports.d.ts` and `src/components.d.ts` — do not hand-edit.
## Styling
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js` — theme is CSS-only).
- PrimeVue `Lara` preset wired in `main.ts`. Use PrimeVue components first; reach for raw HTML when PrimeVue has no equivalent.
- `src/style.css` defines a `.field` label helper used across forms.
## Adding a new view / feature
1. Add typed function(s) to `services/backend.ts` or `services/modules.ts`, types to `types/index.ts`.
2. Create the `.vue` under the matching `views/<area>/` folder.
3. Register the route in `router/index.ts` — pick the right layout (`MainLayout` for top-level, `ProjectLayout` for project-scoped tabs).
4. Use auto-imported PrimeVue components and composables — no manual `import` for them.
## Container
`Dockerfile` is a multi-stage build (node:24-alpine → nginx:1.27-alpine). `nginx.conf` serves the SPA and proxies `/api/` and `/openapi/` to `http://backend:8080`. Production container listens on 80; `docker-compose.yml` maps host `:5173 → 80`.
+546
View File
@@ -0,0 +1,546 @@
---
version: alpha
name: Discord Analysis
description: An analysis of Discord's design language — a loud, playful gaming-native system built on a deep-indigo canvas lit by Blurple, electric green, and vibrant magenta gradients. Heavy ABC Ginto Nord display type shouts in all-caps over generously rounded media, gradient feature panels, and full-bleed Blurple bands; the mood is arcade-energetic, never corporate.
colors:
primary: "#5865f2"
on-primary: "#ffffff"
green: "#35ed7e"
magenta: "#ec48bd"
link: "#00b0f4"
canvas: "#0a0d3a"
surface-indigo: "#1e2353"
surface-onyx: "#23272a"
surface-black: "#000000"
ink: "#ffffff"
ink-dark: "#000000"
muted: "#333333"
hairline: "#23272a"
typography:
display-xl:
fontFamily: ABC Ginto Nord
fontSize: 82px
fontWeight: 800
lineHeight: 1.0
letterSpacing: 0
display-lg:
fontFamily: ABC Ginto Nord
fontSize: 62px
fontWeight: 800
lineHeight: 1.05
letterSpacing: 0
display-md:
fontFamily: ABC Ginto Nord
fontSize: 56px
fontWeight: 700
lineHeight: 1.05
letterSpacing: 0
heading-lg:
fontFamily: ABC Ginto Nord
fontSize: 48px
fontWeight: 700
lineHeight: 1.1
letterSpacing: 0
heading-sm:
fontFamily: ABC Ginto Nord
fontSize: 22px
fontWeight: 700
lineHeight: 1.2
letterSpacing: 0
body-lg:
fontFamily: ABC Ginto
fontSize: 20px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
link-lg:
fontFamily: ABC Ginto
fontSize: 18px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
body:
fontFamily: ggsans
fontSize: 16px
fontWeight: 400
lineHeight: 1.5
letterSpacing: 0
link:
fontFamily: ABC Ginto
fontSize: 16px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
link-sm:
fontFamily: ABC Ginto
fontSize: 14px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
rounded:
xs: 6px
sm: 12px
md: 14px
lg: 16px
xl: 40px
pill: 50px
jumbo: 120px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 20px
xl: 24px
xxl: 32px
section: 40px
components:
nav-bar:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.link}"
padding: "{spacing.md} {spacing.xl}"
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.link-lg}"
rounded: "{rounded.sm}"
padding: "{spacing.lg} {spacing.xl}"
button-green:
backgroundColor: "{colors.green}"
textColor: "{colors.ink-dark}"
typography: "{typography.link-lg}"
rounded: "{rounded.sm}"
padding: "{spacing.sm} {spacing.xl}"
button-white:
backgroundColor: "{colors.ink}"
textColor: "{colors.ink-dark}"
typography: "{typography.link}"
rounded: "{rounded.lg}"
padding: "{spacing.xs} {spacing.md}"
button-ghost:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.link}"
rounded: "{rounded.lg}"
padding: "{spacing.md}"
button-ghost-sm:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.link-sm}"
rounded: "{rounded.xs}"
padding: "{spacing.sm} {spacing.xxl}"
hero:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-xl}"
padding: "{spacing.section}"
feature-card-gradient:
backgroundColor: "{colors.magenta}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.section}"
feature-card-dark:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xxl}"
showcase-band-black:
backgroundColor: "{colors.surface-black}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.section}"
stat-card:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink}"
typography: "{typography.display-md}"
rounded: "{rounded.xl}"
padding: "{spacing.xxl}"
step-card:
backgroundColor: "{colors.magenta}"
textColor: "{colors.ink}"
typography: "{typography.heading-sm}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
cta-band:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink}"
typography: "{typography.display-md}"
rounded: "{rounded.xl}"
padding: "{spacing.section}"
marquee-band:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink}"
typography: "{typography.display-lg}"
padding: "{spacing.lg}"
pricing-table:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
game-rank-feature:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.heading-sm}"
rounded: "{rounded.lg}"
padding: "{spacing.md}"
game-rank-row:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.md}"
padding: "{spacing.sm} {spacing.md}"
faq-accordion:
backgroundColor: "{colors.surface-indigo}"
textColor: "{colors.ink}"
typography: "{typography.link-lg}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
badge:
backgroundColor: "{colors.magenta}"
textColor: "{colors.ink}"
typography: "{typography.link-sm}"
rounded: "{rounded.lg}"
padding: "{spacing.xxs} {spacing.sm}"
footer:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.link}"
padding: "{spacing.section}"
# ─── Examples (illustrative) — auto-derived; resolve any TO_FILL markers below ───
ex-pricing-tier:
description: "Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface."
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
borderColor: "{colors.hairline}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
ex-pricing-tier-featured:
description: "Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode)."
backgroundColor: "{colors.ink}"
textColor: "{colors.on-primary}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
ex-product-selector:
description: "What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery)."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
ex-cart-drawer:
description: "Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart)."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
item-divider: "{colors.hairline}"
ex-app-shell-row:
description: "Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator."
backgroundColor: "{colors.canvas}"
activeIndicator: "{colors.primary}"
rounded: "{rounded.md}"
padding: "{spacing.sm} {spacing.md}"
ex-data-table-cell:
description: "Default data-table th + td chrome. Header uses small link-caps typography; body uses body."
headerBackground: "{colors.surface-indigo}"
headerTypography: "{typography.link-sm}"
bodyTypography: "{typography.body}"
cellPadding: "{spacing.sm} {spacing.md}"
rowBorder: "{colors.hairline}"
ex-auth-form-card:
description: "Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
ex-modal-card:
description: "Modal dialog surface — same chrome as feature-card with elevated shadow."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
ex-empty-state-card:
description: "Empty-state illustration frame."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.section}"
captionTypography: "{typography.body}"
ex-toast:
description: "Toast notification surface — feature-card shape + medium shadow."
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.sm} {spacing.md}"
typography: "{typography.body}"
---
## Overview
Discord's marketing design is loud on purpose. The pages live on a deep-indigo canvas (`{colors.canvas}`#0a0d3a) that is rarely still: it is washed by an animated mesh of Blurple, violet, and vibrant magenta, then punctuated by full-bleed gradient bands and oversized rounded media. Where most product sites whisper in restrained neutrals, Discord shouts in heavy all-caps ABC Ginto Nord, stacks playful 3D character art, and lets a single electric green CTA (`{colors.green}`) pop against the cool indigo. The whole system reads like an arcade cabinet: energetic, saturated, unmistakably gaming-native.
The brand anchor is **Blurple** (`{colors.primary}`#5865f2) — Discord's signature indigo-violet. It owns the primary CTA, the marquee and CTA bands, stat cards, and the brand mark. Around it orbit two supporting accents: the electric **green** (`{colors.green}`) used for the highest-intent "get started" actions, and a vibrant **magenta** (`{colors.magenta}`#ec48bd) that fills the gradient feature panels and step cards. Surfaces stack in cool darks — the indigo canvas, a raised indigo panel (`{colors.surface-indigo}`), an onyx UI card (`{colors.surface-onyx}`), and pure black (`{colors.surface-black}`) showcase bands.
Geometry is soft and generous. Everyday controls round at `{rounded.sm}` (12px) and `{rounded.lg}` (16px); media tiles and feature panels bow out at `{rounded.xl}` (40px) and beyond; the most expressive shapes reach `{rounded.jumbo}` (120px) and pill caps. Nothing is sharp. The result is friendly, toy-like, and built to make software feel like play.
**Key Characteristics:**
- Deep-indigo canvas (`{colors.canvas}`) lit by an animated Blurple-to-magenta gradient mesh — never a flat or neutral background.
- One iconic brand colour: Blurple (`{colors.primary}`) owns CTAs, bands, and the brand mark; electric green (`{colors.green}`) is reserved for highest-intent actions.
- Vibrant magenta (`{colors.magenta}`) gradient feature panels and step cards carry the playful, saturated energy.
- Heavy all-caps display type in `{typography.display-xl}` (ABC Ginto Nord 800) shouting over generously rounded media.
- Soft, toy-like geometry: 1216px on controls, 40px+ on media, up to `{rounded.jumbo}` on signature shapes.
- Page rhythm: dark-indigo hero → gradient + dark feature cards → black showcase band → Blurple marquee/CTA band → giant wordmark footer.
## Colors
> Source pages analyzed: home, ads/quests, nitro, trending-games. Blurple, green, magenta, white display type, and the deep-indigo canvas recur on every page; nitro adds the pricing table, trending-games adds the ranked game list.
### Brand & Accent
- **Blurple** (`{colors.primary}`#5865f2): The iconic brand colour. Primary CTA fill, marquee and CTA bands, stat cards, brand mark. The single most-used action colour.
- **Electric Green** (`{colors.green}`#35ed7e): Reserved for the highest-intent CTA ("get started" / "download"), always paired with `{colors.ink-dark}` text.
- **Vibrant Magenta** (`{colors.magenta}`#ec48bd): The saturated pink that fills gradient feature panels, step cards, and badges — the playful counterweight to the cool indigo.
- **Link Cyan** (`{colors.link}`#00b0f4): Inline text-link colour on dark surfaces.
### Surface
- **Indigo Canvas** (`{colors.canvas}`#0a0d3a): The deep-indigo page base, washed by the animated brand-gradient mesh.
- **Raised Indigo** (`{colors.surface-indigo}`#1e2353): One step up from canvas — dark feature cards, pricing table, game-rank rows, ghost buttons.
- **Onyx** (`{colors.surface-onyx}`#23272a): Discord's classic dark-UI surface; product-chrome cards and dividers.
- **Black** (`{colors.surface-black}`#000000): Full-black showcase bands framing product media.
### Text
- **White** (`{colors.ink}`#ffffff): All display and body text on the dark canvas. The dominant text colour.
- **Ink** (`{colors.ink-dark}`#000000): Text on light fills — the white button and green CTA.
- **Muted Ink** (`{colors.muted}`#333333): Secondary text on the occasional light surface (white product-mockup cards).
### Brand Gradient
The hero and feature panels ride an animated mesh that sweeps from `{colors.primary}` (Blurple) through a deep violet into the vibrant `{colors.magenta}`, resolving back into the `{colors.canvas}` indigo at the edges. It is the brand's defining atmospheric signature — always in motion, never a flat fill.
## Typography
### Font Family
- **ABC Ginto Nord** — the heavy display face. All marketing headlines, set in 700800 weight, frequently all-caps. Wide, confident, slightly condensed character that reads as "gaming."
- **ABC Ginto** — the lighter companion for lead paragraphs, links, and buttons (weight 500).
- **ggsans** — Discord's in-product UI sans, used for dense body copy (16px / 400).
**Note on font substitutes:** ABC Ginto Nord and ggsans are proprietary. For an open-source rebuild, pair a heavy geometric grotesque — **Hanken Grotesk** or **Space Grotesk** at 700800 — for display, with **Inter** or **Plus Jakarta Sans** for body and UI. Keep headlines bold and tracked tight; the loud, confident display weight is the brand's voice.
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.display-xl}` | 82px | 800 | 1.0 | 0 | Hero headline (all-caps) |
| `{typography.display-lg}` | 62px | 800 | 1.05 | 0 | Marquee band, major headline |
| `{typography.display-md}` | 56px | 700 | 1.05 | 0 | Section headline, CTA band |
| `{typography.heading-lg}` | 48px | 700 | 1.1 | 0 | Sub-section heading |
| `{typography.heading-sm}` | 22px | 700 | 1.2 | 0 | Card heading, step label |
| `{typography.body-lg}` | 20px | 500 | 1.4 | 0 | Lead paragraph |
| `{typography.link-lg}` | 18px | 500 | 1.4 | 0 | Large button label, prominent link |
| `{typography.body}` | 16px | 400 | 1.5 | 0 | Default body copy (ggsans) |
| `{typography.link}` | 16px | 500 | 1.4 | 0 | Nav link, button label |
| `{typography.link-sm}` | 14px | 500 | 1.4 | 0 | Small link, badge, fine print |
### Principles
- Headlines are short, declarative, and frequently ALL-CAPS in ABC Ginto Nord 800 — the loudest element on every page.
- Body copy drops to the lighter ABC Ginto / ggsans 400500 so the display type stays the hero.
- The display-to-body weight jump (800 → 400/500) is dramatic on purpose; there is no timid mid-weight in between.
## Layout
### Spacing System
- **Base unit**: 8px.
- **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 40px.
- Card interiors run `{spacing.xl}``{spacing.section}`; buttons pad `{spacing.sm}``{spacing.lg}` vertical by `{spacing.xl}` horizontal.
### Grid & Container
- Centered max-width content column (~1200px) on the full-bleed indigo canvas.
- Feature sections alternate a two-column split (text + media) with stacked full-width gradient/dark cards.
- Marquee, CTA, and showcase bands are full-bleed colour fields with their own rounded inner containers.
### Whitespace Philosophy
Sections breathe through large vertical gaps of indigo, then collide with saturated colour bands for rhythm. Inside cards, generous padding lets oversized 3D art and product mockups float with air.
### Responsive Strategy
#### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 768px | Single column; nav collapses to logo + hamburger; CTAs stack full-width |
| Tablet | 7681023px | Two-column splits begin stacking; gradient cards go full-width |
| Laptop | 10241279px | Container narrows; multi-column grids retained |
| Desktop | ≥ 1280px | Full multi-column grids; centered ~1200px column |
(Discord ships an unusually dense breakpoint ladder — dozens of stops between 240px and ~2000px — to keep the oversized display type and 3D art balanced at every width.)
#### Touch Targets
`{components.button-primary}` and `{components.button-green}` clear ≥44px tap height via their vertical padding. Nav links and game-rank rows meet the same minimum on mobile.
#### Collapsing Strategy
The dark top nav (logo · links · Login · Download CTA) collapses to logo + hamburger below 768px. Two-column feature rows stack media-over-text; gradient and dark cards span full width. The nitro pricing table becomes horizontally scrollable; the trending-games ranked list keeps its row layout but drops secondary columns.
#### Image Behavior
Product mockups and 3D character art sit inside rounded media frames (`{rounded.lg}``{rounded.xl}`) or bleed past card edges as decorative props. Media scales fluidly within its container and keeps its corner radius at every width.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No shadow; separation by colour field + large radius | Most cards, colour bands |
| 1 — Soft float | `0 3px 68px rgba(69,42,124,0.1)` — wide, violet-tinted, very diffuse | Floating media cards, elevated mockups |
Discord leans on **colour, gradient, and radius** for depth far more than on shadow. The one extracted shadow is a wide, violet-tinted diffuse glow that lifts product media off the indigo canvas without a hard edge.
### Decorative Depth
- The animated Blurple-to-magenta gradient mesh creates depth by motion and hue rather than shadow.
- 3D character art and product props overlap card edges to build foreground/background layering.
- Full-bleed colour bands (Blurple, black) push depth by contrast against the indigo scroll.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.xs}` | 6px | Small ghost buttons, compact chips |
| `{rounded.sm}` | 12px | Primary / green CTA buttons, links, table cells |
| `{rounded.md}` | 14px | Game-rank rows, mid controls |
| `{rounded.lg}` | 16px | White / ghost buttons, cards, media frames |
| `{rounded.xl}` | 40px | Gradient feature panels, large media tiles |
| `{rounded.pill}` | 50px | Pill caps, badges, avatar chips |
| `{rounded.jumbo}` | 120px | Signature oversized rounded shape cards |
| `{rounded.full}` | 9999px | Circular avatars and icon buttons |
### Photography Geometry
Media is presented at soft-cornered rectangles (`{rounded.lg}``{rounded.xl}`), never hard-edged. The hero media block uses a directional bottom-only radius (88px bottom corners) for a swooping base. Avatars and circular icon controls are fully round.
## Components
> No hover states documented. Component specs cover Default and Active/Pressed only; variants are separate `components:` entries.
### Buttons
**`button-primary`** — the Blurple pill CTA
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.link-lg}`, rounded `{rounded.sm}`, padding `{spacing.lg} {spacing.xl}`. The everyday action button across hero and feature sections.
**`button-green`** — the electric-green high-intent CTA ("Get Started" / "Download")
- Background `{colors.green}`, text `{colors.ink-dark}`, type `{typography.link-lg}`, rounded `{rounded.sm}`, padding `{spacing.sm} {spacing.xl}`. The highest-visibility action; black label for contrast.
**`button-white`** — white solid button
- Background `{colors.ink}`, text `{colors.ink-dark}`, type `{typography.link}`, rounded `{rounded.lg}`, padding `{spacing.xs} {spacing.md}`.
**`button-ghost`** — translucent indigo button on dark surfaces
- Background `{colors.surface-indigo}`, text `{colors.ink}`, type `{typography.link}`, rounded `{rounded.lg}`, padding `{spacing.md}`.
**`button-ghost-sm`** — compact ghost button (quests CTA row)
- Background `{colors.surface-indigo}`, text `{colors.ink}`, type `{typography.link-sm}`, rounded `{rounded.xs}`, padding `{spacing.sm} {spacing.xxl}`.
### Cards & Containers
**`hero`** — dark-indigo hero
- Indigo `{colors.canvas}` field with the animated gradient mesh, white `{colors.ink}` all-caps headline at `{typography.display-xl}`, lead paragraph, and a CTA pair. The hero media block carries a swooping bottom-only radius.
**`feature-card-gradient`** — vibrant magenta gradient feature panel
- Background `{colors.magenta}` gradient, white text, `{rounded.xl}` (40px), padding `{spacing.section}`. Frames a product mockup or 3D prop.
**`feature-card-dark`** — raised dark feature card
- Background `{colors.surface-indigo}`, white text, `{rounded.xl}`, padding `{spacing.xxl}`. Holds product screenshots / chat mockups.
**`showcase-band-black`** — full-black product showcase band
- Background `{colors.surface-black}`, white text, `{rounded.xl}`, padding `{spacing.section}`. Frames a hero product demo.
**`stat-card`** — big-number stat card (quests)
- Background `{colors.primary}` (Blurple), white text, headline at `{typography.display-md}`, `{rounded.xl}`, padding `{spacing.xxl}`.
**`step-card`** — numbered step panel (1/2/3 process)
- Background `{colors.magenta}` gradient, white text, label at `{typography.heading-sm}`, `{rounded.lg}`, padding `{spacing.xl}`.
**`cta-band`** — full-bleed Blurple CTA band
- Background `{colors.primary}`, white headline at `{typography.display-md}`, `{rounded.xl}`, padding `{spacing.section}`, with a `button-white` or `button-green` CTA.
**`marquee-band`** — scrolling all-caps marquee ("PLAY · CHAT · HANG OUT")
- Background `{colors.primary}`, white display text at `{typography.display-lg}`, padding `{spacing.lg}`.
**`faq-accordion`** — collapsible FAQ row (nitro)
- Background `{colors.surface-indigo}`, white text, question at `{typography.link-lg}`, `{rounded.lg}`, padding `{spacing.xl}`.
### Inputs & Forms
> Discord's marketing pages surface no standalone text inputs; forms route to the app. The kit-mirror `ex-*` form surfaces below model inputs against the brand's `{rounded.lg}` surfaces and `{colors.surface-indigo}` fills.
### Navigation
**`nav-bar`** — dark top navigation
- Indigo `{colors.canvas}` bar, white `{colors.ink}` links at `{typography.link}`, padding `{spacing.md} {spacing.xl}`. Slots: Discord logo · text links · "Login" · a Blurple/green Download CTA. Collapses to logo + hamburger on mobile.
**`footer`** — dark link footer
- Indigo `{colors.canvas}`, white links at `{typography.link}`, padding `{spacing.section}`, organized into multi-column link groups above a giant "Discord" wordmark.
### Signature Components
**`pricing-table`** — nitro plan comparison table
- Raised-indigo `{colors.surface-indigo}` surface, body text at `{typography.body}`, `{rounded.lg}`, padding `{spacing.xl}`. Two plan columns (Nitro Basic / Nitro) with the popular tier carrying a `{colors.magenta}` badge, each row ending in a `button-primary` "Subscribe".
**`game-rank-feature`** — top-ranked game card (trending-games #1/#2/#3)
- Raised-indigo `{colors.surface-indigo}` card, large media, rank number + title at `{typography.heading-sm}`, `{rounded.lg}`, padding `{spacing.md}`.
**`game-rank-row`** — ranked game list row
- Raised-indigo `{colors.surface-indigo}`, body text at `{typography.body}`, `{rounded.md}`, padding `{spacing.sm} {spacing.md}`. Rank · icon · title · metadata columns.
**`badge`** — small rounded tag / category chip
- Background `{colors.magenta}`, white text at `{typography.link-sm}`, `{rounded.lg}`, padding `{spacing.xxs} {spacing.sm}`.
### Examples (illustrative)
> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass.
**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface.
- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding`
**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode).
- Properties: `backgroundColor`, `textColor`, `rounded`, `padding`
**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery).
- Properties: `backgroundColor`, `rounded`, `padding`
**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart).
- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider`
**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator.
- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding`
**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm.
- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder`
**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside.
- Properties: `backgroundColor`, `rounded`, `padding`
**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow.
- Properties: `backgroundColor`, `rounded`, `padding`
**`ex-empty-state-card`** — Empty-state illustration frame.
- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography`
**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow.
- Properties: `backgroundColor`, `rounded`, `padding`, `typography`
## Do's and Don'ts
### Do
- Lead with the deep-indigo canvas (`{colors.canvas}`) and let the animated Blurple-to-magenta gradient carry atmosphere.
- Reserve `{colors.green}` for the single highest-intent CTA on a page; use `{colors.primary}` Blurple for everything else action-related.
- Shout with `{typography.display-xl}` ABC Ginto Nord in all-caps for headlines; drop hard to `{typography.body}` for copy.
- Round generously — `{rounded.sm}``{rounded.lg}` on controls, `{rounded.xl}`+ on media and feature panels.
- Frame product mockups inside `{colors.magenta}` gradient panels or `{colors.surface-indigo}` dark cards.
- Let 3D character art and props overlap card edges to build playful depth.
### Don't
- Don't flatten the canvas to a neutral grey or pure black — the indigo + gradient mesh is the brand.
- Don't use `{colors.green}` as a general accent; it is the high-intent CTA only.
- Don't set headlines in a timid mid-weight — display type is 700800 ABC Ginto Nord or it loses the brand voice.
- Don't square off media or cards; the soft `{rounded.xl}`+ geometry is core to the playful tone.
- Don't lean on drop shadows for depth; depth comes from colour, gradient, and overlapping art.
- Don't introduce a fourth loud accent — Blurple, green, and magenta are the full chord.
+1 -2
View File
@@ -5,8 +5,7 @@ COPY package*.json ./
RUN npm install
COPY . .
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY .env.example .env
RUN npm run build
FROM nginx:1.27-alpine
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>My Workspace</title>
<script>
;(function () {
-12
View File
@@ -5,18 +5,6 @@ server {
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /openapi/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<router-view />
<Toast position="bottom-right" />
<ConfirmDialog />
<ConfirmDialog style="width: min(500px, 92vw)" />
</template>
+9 -3
View File
@@ -13,13 +13,15 @@ declare module 'vue' {
export interface GlobalComponents {
Avatar: typeof import('primevue/avatar')['default']
Button: typeof import('primevue/button')['default']
Card: typeof import('primevue/card')['default']
Checkbox: typeof import('primevue/checkbox')['default']
Column: typeof import('primevue/column')['default']
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
DataTable: typeof import('primevue/datatable')['default']
DatePicker: typeof import('primevue/datepicker')['default']
Dialog: typeof import('primevue/dialog')['default']
IconField: typeof import('primevue/iconfield')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputNumber: typeof import('primevue/inputnumber')['default']
InputText: typeof import('primevue/inputtext')['default']
Menu: typeof import('primevue/menu')['default']
Message: typeof import('primevue/message')['default']
@@ -28,11 +30,15 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
Splitter: typeof import('primevue/splitter')['default']
SplitterPanel: typeof import('primevue/splitterpanel')['default']
Tab: typeof import('primevue/tab')['default']
TabList: typeof import('primevue/tablist')['default']
TabPanel: typeof import('primevue/tabpanel')['default']
TabPanels: typeof import('primevue/tabpanels')['default']
Tabs: typeof import('primevue/tabs')['default']
Tag: typeof import('primevue/tag')['default']
Textarea: typeof import('primevue/textarea')['default']
Toast: typeof import('primevue/toast')['default']
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
TreeTable: typeof import('primevue/treetable')['default']
}
}
+20
View File
@@ -0,0 +1,20 @@
<template>
<DataTable
v-bind="$attrs"
scrollable
scrollHeight="flex"
:rowsPerPageOptions="rowsPerPageOptions"
class="min-h-0 flex-1"
>
<slot />
</DataTable>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
rowsPerPageOptions?: number[]
}>(),
{ rowsPerPageOptions: () => [10, 20, 50] },
)
</script>
-70
View File
@@ -1,70 +0,0 @@
<template>
<div v-if="doc" class="flex h-full flex-col">
<div class="flex items-start gap-2 border-b border-slate-200 px-4 py-3 dark:border-slate-700 lg:gap-3 lg:px-5">
<Button
icon="pi pi-arrow-left"
rounded
text
severity="secondary"
class="lg:hidden"
aria-label="Back to documents"
@click="$emit('back')"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-lg font-semibold">{{ doc.title }}</div>
<div class="flex items-center gap-1 text-[0.8rem] text-slate-500 dark:text-slate-400">
<i class="pi pi-history"></i> Updated {{ formatDate(doc.updatedAt) }}
<span v-if="saveState === 'saved'" class="text-[0.75rem] text-emerald-500">Saved</span>
<span v-else-if="saveState === 'saving'" class="text-[0.75rem] text-amber-500">Saving</span>
</div>
</div>
<div class="editor-actions flex gap-2">
<Button v-if="canEdit" icon="pi pi-pencil" severity="secondary" outlined size="small" label="Rename" @click="$emit('rename')" />
<Button v-if="canEdit" icon="pi pi-folder-open" severity="secondary" outlined size="small" label="Move" @click="$emit('move')" />
<Button v-if="canDelete" icon="pi pi-trash" severity="danger" outlined size="small" label="Delete" @click="$emit('delete')" />
</div>
</div>
<RichTextEditor
:model-value="modelValue"
class="flex-1 overflow-auto px-5 py-4"
@update:model-value="$emit('update:modelValue', $event)"
/>
</div>
<div v-else class="flex h-full flex-col items-center justify-center gap-2">
<i class="pi pi-file dark:text-slate-600" style="font-size: 2.5rem; color: #cbd5e1"></i>
<p class="text-slate-500 dark:text-slate-400">Select a document from the tree to start editing</p>
</div>
</template>
<script setup lang="ts">
import RichTextEditor from './RichTextEditor.vue'
import type { DocumentItem } from '../types'
defineProps<{
doc: DocumentItem | null
modelValue: string
saveState: 'idle' | 'saving' | 'saved'
canEdit: boolean
canDelete: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
rename: []
move: []
delete: []
back: []
}>()
function formatDate(value: string) {
return new Date(value).toLocaleString()
}
</script>
<style scoped>
@media (max-width: 1023px) {
.editor-actions :deep(.p-button-label) {
display: none;
}
}
</style>
-65
View File
@@ -1,65 +0,0 @@
<template>
<div>
<div
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-1 hover:bg-slate-100 dark:hover:bg-slate-800"
:class="{ 'bg-blue-100 dark:bg-blue-900/50': isSelected }"
@click="onRowClick"
>
<span class="tree-chevron" @click.stop="onToggle">
<i :class="isFolder && expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="text-[0.7rem] text-slate-500 dark:text-slate-400"></i>
</span>
<i
:class="isFolder ? (expanded ? 'pi pi-folder-open' : 'pi pi-folder') : 'pi pi-file'"
:style="{ color: isFolder ? '#3b82f6' : '#94a3b8' }"
></i>
<span>{{ node.title }}</span>
</div>
<div v-if="expanded && node.children.length" class="ml-[18px] border-l border-slate-200 pl-2 dark:border-slate-700">
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:selected-id="selectedId"
@select="$emit('select', $event)"
@toggle="$emit('toggle', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { DocumentNode } from '../types'
defineOptions({ name: 'TreeNode' })
const props = defineProps<{
node: DocumentNode
selectedId: string | null
}>()
const emit = defineEmits<{
select: [id: string]
toggle: [id: string]
}>()
const expanded = ref(false)
const isFolder = computed(() => props.node.type === 'Folder')
const isSelected = computed(() => props.node.id === props.selectedId)
function onRowClick() {
if (isFolder.value) {
expanded.value = !expanded.value
emit('toggle', props.node.id)
} else {
emit('select', props.node.id)
}
}
function onToggle() {
if (isFolder.value) {
expanded.value = !expanded.value
emit('toggle', props.node.id)
}
}
</script>
+88 -21
View File
@@ -1,44 +1,85 @@
<template>
<div class="flex h-full flex-col overflow-hidden p-3">
<div>
<div class="panel flex h-full flex-col overflow-hidden">
<div class="flex flex-col gap-3 border-b p-3 sm:flex-row sm:items-center" style="border-color: var(--hairline)">
<IconField class="min-w-0 flex-1">
<InputIcon class="pi pi-search" />
<InputText
:model-value="searchTerm"
placeholder="Search documents..."
class="w-full"
placeholder="Search documents"
class="search-input w-full"
@update:model-value="onSearch"
/>
<div v-if="canCreate" class="mt-2 flex gap-2">
<Button label="Doc" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Document')" />
<Button label="Folder" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Folder')" />
</IconField>
<div v-if="canCreate" class="flex shrink-0 gap-2">
<Button label="Document" icon="pi pi-plus" size="small" @click="$emit('create', 'Document')" />
<Button label="Folder" icon="pi pi-folder-open" severity="secondary" outlined size="small" @click="$emit('create', 'Folder')" />
</div>
<div class="mt-2 text-[0.8rem] text-slate-500 dark:text-slate-400">
</div>
<p v-if="canCreate" class="muted-note border-b px-3 py-2" style="border-color: var(--hairline)">
{{ creatingLabel }}
</p>
<div class="min-h-0 flex-1 overflow-auto">
<TreeTable
v-model:selectionKeys="selectionKeys"
:value="nodes"
:loading="loading"
selectionMode="single"
@node-select="onNodeSelect"
>
<Column field="title" header="Name" expander style="min-width: 240px">
<template #body="{ node }">
<span class="inline-flex items-center gap-2">
<i
class="pi"
:class="node.data.type === 'Folder' ? 'pi-folder text-fuchsia-500' : 'pi-file'"
:style="node.data.type === 'Folder' ? undefined : { color: 'var(--ink-muted)' }"
></i>
<span :class="node.key === selectedId ? 'font-semibold' : ''">{{ node.data.title }}</span>
</span>
</template>
</Column>
<Column header="Owner" style="width: 160px" class="hidden lg:table-cell">
<template #body="{ node }">
<span class="muted-note">{{ resolveUserName(node.data.createdBy) }}</span>
</template>
</Column>
<Column header="Last edited by" style="width: 160px" class="hidden xl:table-cell">
<template #body="{ node }">
<span class="muted-note">{{ node.data.updatedBy ? resolveUserName(node.data.updatedBy) : '—' }}</span>
</template>
</Column>
<Column header="Updated" style="width: 130px" class="hidden sm:table-cell">
<template #body="{ node }">
<span class="muted-note">{{ formatDate(node.data.updatedAt) }}</span>
</template>
</Column>
<template #empty>
<div class="grid place-items-center gap-2 px-4 py-14 text-center">
<i class="pi pi-file-edit text-2xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">No documents yet</p>
<p class="muted-note max-w-[280px]">
{{ canCreate ? 'Create a document to start writing, or a folder to group them.' : 'Ask a project owner for document access.' }}
</p>
</div>
</div>
<div class="mt-2 flex-1 overflow-auto">
<TreeNode
v-for="root in tree"
:key="root.id"
:node="root"
:selected-id="selectedId"
@select="$emit('select', $event)"
/>
<div v-if="!tree.length && !loading" class="p-4 text-center text-slate-500 dark:text-slate-400">No documents yet</div>
</template>
</TreeTable>
</div>
</div>
</template>
<script setup lang="ts">
import TreeNode from './DocumentTreeNode.vue'
import type { DocumentNode, DocumentType } from '../types'
import type { DocumentNode, DocumentType, ProjectMember } from '../types'
defineProps<{
const props = defineProps<{
tree: DocumentNode[]
loading: boolean
selectedId: string | null
searchTerm: string
creatingLabel: string
canCreate: boolean
members: ProjectMember[]
}>()
const emit = defineEmits<{
@@ -50,4 +91,30 @@ const emit = defineEmits<{
function onSearch(value: unknown) {
emit('update:searchTerm', typeof value === 'string' ? value.trim() : '')
}
interface DocumentTreeTableNode {
key: string
data: DocumentNode
children: DocumentTreeTableNode[]
}
function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode {
return { key: node.id, data: node, children: node.children.map(toTreeTableNode) }
}
const nodes = computed(() => props.tree.map(toTreeTableNode))
const selectionKeys = ref<Record<string, boolean>>({})
function resolveUserName(userId: string) {
return props.members.find((m) => m.userId === userId)?.displayName ?? '—'
}
function formatDate(v?: string | null) {
const date = new Date(v ?? '')
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleDateString()
}
function onNodeSelect(node: { key?: string; data?: DocumentNode }) {
if (node.key && node.data?.type === 'Document') emit('select', node.key)
}
</script>
+154
View File
@@ -0,0 +1,154 @@
<template>
<Dialog
:visible="visible"
:modal="true"
:dismissable-mask="false"
:pt="{ root: { class: 'doc-dialog' }, content: { class: 'doc-dialog-content' } }"
@update:visible="onClose"
>
<template #container>
<header class="flex shrink-0 items-start gap-3 border-b px-5 py-3" style="border-color: var(--hairline)">
<i
class="mt-1 pi"
:class="doc?.type === 'Folder' ? 'pi-folder text-fuchsia-500' : 'pi-file-edit'"
:style="doc?.type === 'Folder' ? undefined : { color: 'var(--primary)' }"
></i>
<div class="min-w-0 flex-1">
<h2 class="truncate font-display text-[19px] font-extrabold leading-tight" style="color: var(--ink)">
{{ doc?.title }}
</h2>
<p class="muted-note mt-0.5">
Updated {{ formatDate(doc?.updatedAt) }}
<span v-if="saveState === 'saved'" class="ml-2 font-semibold text-green-600 dark:text-green-400">Saved</span>
<span v-else-if="saveState === 'saving'" class="ml-2 font-semibold" style="color: var(--ink-muted)">Saving</span>
<span v-else-if="editing" class="ml-2 font-semibold" style="color: var(--magenta)">Editing</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<template v-if="editing">
<Button label="Cancel" severity="secondary" text size="small" @click="$emit('cancel-edit')" />
<Button
label="Save"
icon="pi pi-check"
size="small"
:loading="saveState === 'saving'"
:disabled="saveState === 'saving'"
@click="$emit('save')"
/>
</template>
<template v-else>
<Button v-if="canEdit" icon="pi pi-pencil" label="Edit" size="small" @click="$emit('edit')" />
<Button v-if="canEdit" icon="pi pi-folder-open" text severity="secondary" size="small" aria-label="Move to folder" @click="$emit('move')" />
<Button v-if="canDelete" icon="pi pi-trash" text severity="danger" size="small" aria-label="Delete" @click="$emit('delete')" />
<Button
v-if="canEdit"
icon="pi pi-ellipsis-h"
text
severity="secondary"
size="small"
aria-label="More actions"
@click="toggleMenu"
/>
<Menu ref="menu" :model="menuItems" popup />
</template>
<Button icon="pi pi-times" text severity="secondary" size="small" aria-label="Close" @click="onClose(false)" />
</div>
</header>
<div class="min-h-0 flex-1 overflow-hidden" style="background: var(--panel)">
<RichTextEditor
v-if="editing"
class="h-full"
:model-value="modelValue"
@update:model-value="onContentChange"
/>
<div v-else-if="doc?.type === 'Document'" class="h-full overflow-auto p-4">
<article class="ck-content document-content" v-html="displayHtml"></article>
</div>
<div v-else class="grid h-full place-items-center">
<p class="muted-note">Folder open a document inside it to read or edit.</p>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import RichTextEditor from './RichTextEditor.vue'
import type { DocumentItem } from '../types'
const props = defineProps<{
visible: boolean
doc: DocumentItem | null
modelValue: string
editing: boolean
saveState: 'idle' | 'saving' | 'saved'
canEdit: boolean
canDelete: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:modelValue': [value: string]
edit: []
'cancel-edit': []
save: []
rename: []
move: []
delete: []
}>()
const menu = ref()
function toggleMenu(event: Event) {
menu.value?.toggle(event)
}
const menuItems = computed(() => {
const items: Record<string, unknown>[] = []
if (props.canEdit) {
items.push({ label: 'Rename', icon: 'pi pi-pencil', command: () => emit('rename') })
}
return items
})
function onClose(value: unknown) {
emit('update:visible', typeof value === 'boolean' ? value : false)
}
function onContentChange(value: string) {
emit('update:modelValue', value)
}
const displayHtml = computed(() => props.doc?.content || '<p></p>')
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : '—'
}
</script>
<style>
/* Near-fullscreen writing surface — the editor is the point of this screen */
.doc-dialog {
display: flex;
flex-direction: column;
width: 96vw;
max-width: 1440px;
height: 94dvh;
overflow: hidden;
border-radius: 16px;
background: var(--panel);
border: 1px solid var(--hairline);
box-shadow: rgba(10, 13, 58, 0.28) 0 18px 60px;
}
</style>
<style scoped>
.document-content {
min-height: 100%;
padding: 20px;
border: 1px solid var(--hairline);
border-radius: 12px;
}
</style>
+15
View File
@@ -1,10 +1,12 @@
<template>
<div class="editor-shell">
<Ckeditor
:editor="Editor"
:model-value="modelValue ?? ''"
:config="editorConfig"
@update:model-value="onUpdate"
/>
</div>
</template>
<script setup lang="ts">
@@ -102,3 +104,16 @@ const editorConfig = {
},
}
</script>
<style scoped>
.editor-shell {
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
}
.editor-shell :deep(.ck.ck-editor) {
flex: 1;
min-height: 0;
}
</style>
+108 -40
View File
@@ -1,27 +1,40 @@
<template>
<div
class="flex h-dvh flex-col lg:grid"
:class="sidebarOpen ? 'lg:grid-cols-[250px_1fr]' : 'lg:grid-cols-1'"
:class="sidebarOpen ? 'lg:grid-cols-[236px_1fr]' : 'lg:grid-cols-1'"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
class="fixed inset-0 z-30 bg-slate-950/60 backdrop-blur-sm lg:hidden"
@click="sidebarOpen = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 flex w-[250px] transform flex-col gap-2 bg-slate-800 p-4 text-slate-200 transition-transform duration-200 lg:static"
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
style="background: var(--panel); border-color: var(--hairline)"
>
<div class="mb-4 flex items-center justify-between text-xl font-bold text-white">
<div class="flex items-center gap-2">
<i class="pi pi-briefcase"></i>
MWS
</div>
<button class="lg:hidden" aria-label="Close menu" @click="sidebarOpen = false">
<i class="pi pi-times text-lg"></i>
<div class="mb-5 flex items-center justify-between px-2">
<router-link to="/dashboard" class="flex items-center gap-2.5">
<span
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white"
style="background: var(--primary)"
>M</span
>
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: var(--ink)">
Workspace
</span>
</router-link>
<button
class="text-slate-400 transition-colors hover:text-slate-900 dark:hover:text-white lg:hidden"
aria-label="Close menu"
@click="sidebarOpen = false"
>
<i class="pi pi-times text-base"></i>
</button>
</div>
<nav class="flex flex-col gap-1">
<nav class="flex flex-col gap-0.5">
<router-link
v-for="item in visibleNavItems"
:key="item.key"
@@ -35,45 +48,76 @@
<i class="pi pi-cog"></i> Settings
</router-link>
</nav>
<div class="flex-1"></div>
<div class="flex items-center gap-2 text-slate-300">
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="normal" />
<span>{{ auth.user?.displayName ?? auth.user?.username }}</span>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-800 dark:bg-slate-900 lg:px-6">
<div class="flex items-center gap-2">
<header
class="flex items-center justify-between gap-3 border-b px-4 py-2.5 lg:px-6"
style="background: var(--panel); border-color: var(--hairline)"
>
<div class="flex min-w-0 items-center gap-2">
<Button
icon="pi pi-bars"
rounded
text
severity="secondary"
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
@click="sidebarOpen = !sidebarOpen"
/>
<span class="truncate text-slate-500 dark:text-slate-400">{{ currentProject?.name ?? 'My Workspace' }}</span>
<nav class="flex min-w-0 items-center gap-1.5 text-[13px]" aria-label="Breadcrumb">
<span class="shrink-0 font-medium" style="color: var(--ink-muted)">
{{ pageTitle }}
</span>
<template v-if="currentProject">
<span style="color: var(--ink-muted)">/</span>
<router-link
:to="{ name: 'project-overview', params: { id: currentProject.id } }"
class="truncate font-medium transition-colors hover:text-slate-900 dark:hover:text-white"
style="color: var(--ink-muted)"
>
{{ currentProject.name }}
</router-link>
</template>
</nav>
</div>
<div class="flex items-center gap-1">
<div class="flex min-w-0 items-center gap-2">
<span class="hidden shrink-0 text-[13px] font-semibold sm:block" style="color: var(--ink)">
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}
</span>
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
rounded
text
severity="secondary"
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggle"
/>
<Menu ref="menu" :model="menuItems" popup />
<Button
icon="pi pi-ellipsis-v"
rounded
text
aria-label="Options"
<button
class="flex w-auto items-center gap-2.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-white/5"
style="border-color: var(--hairline)"
aria-haspopup="true"
@click="toggleMenu"
/>
>
<Avatar :label="initials" :style="{ background: 'var(--primary)', color: '#fff' }" size="normal" />
<span class="hidden min-w-0 sm:block">
<span class="block truncate text-[13px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</span>
<span class="block truncate text-[11px]" style="color: var(--ink-muted)">
@{{ auth.user?.username }}
</span>
</span>
</button>
</div>
</div>
<main class="flex-1 overflow-auto p-4 lg:p-6">
<Menu ref="menu" :model="menuItems" popup />
</header>
<main class="flex-1 overflow-auto">
<div class="mx-auto h-full w-full max-w-[1280px] p-4 sm:p-6 lg:px-8 lg:py-7">
<router-view />
</div>
</main>
</div>
</div>
@@ -88,11 +132,11 @@ import type { Project } from '../types'
const auth = useAuthStore()
const route = useRoute()
const menu = ref()
const sidebarOpen = ref(false)
const theme = useTheme()
const desktopMq = window.matchMedia('(min-width: 1024px)')
const isDesktop = ref(desktopMq.matches)
const sidebarOpen = ref(isDesktop.value)
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
function handleNavClick() {
@@ -102,11 +146,12 @@ function handleNavClick() {
const navItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'pi pi-home', to: '/dashboard' },
{ key: 'projects', label: 'Projects', icon: 'pi pi-folder-open', to: '/projects' },
{ key: 'accounts', label: 'Accounts', icon: 'pi pi-users', to: '/accounts' },
{ key: 'roles', label: 'Roles', icon: 'pi pi-shield', to: '/roles' },
{ key: 'users', label: 'Users', icon: 'pi pi-users', to: '/users' },
]
const visibleNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
const pageTitle = computed(() => (route.meta.title as string | undefined) ?? 'Projects')
const currentProject = ref<Project | null>(null)
async function loadCurrentProject(id: string | string[]) {
@@ -142,6 +187,7 @@ const menuItems = computed(() => [
{
label: auth.user?.displayName ?? auth.user?.username,
items: [
{ label: 'Profile', icon: 'pi pi-user', command: () => router.push('/profile') },
{
label: 'Logout',
icon: 'pi pi-sign-out',
@@ -154,6 +200,8 @@ const menuItems = computed(() => [
},
])
const router = useRouter()
function toggleMenu(event: Event) {
menu.value?.toggle(event)
}
@@ -161,22 +209,42 @@ function toggleMenu(event: Event) {
<style scoped>
.nav-link {
position: relative;
display: flex;
cursor: pointer;
align-items: center;
gap: 0.625rem;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
gap: 0.65rem;
border-radius: 12px;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: #cbd5e1;
color: var(--ink-muted);
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-link i {
font-size: 0.9rem;
}
.nav-link:hover {
background-color: #334155;
color: #fff;
background-color: var(--primary-soft);
color: var(--ink);
}
.nav-link.router-link-active {
background-color: #3b82f6;
color: #fff;
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.app-dark .nav-link.router-link-active {
color: #9ba6f8;
}
/* Active marker: a magenta rail, the one place the second accent shows in the shell */
.nav-link.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 3px;
border-radius: 999px;
background: var(--magenta);
}
</style>
+43 -19
View File
@@ -1,24 +1,48 @@
<template>
<div class="project-layout">
<div class="mb-5 flex gap-1 overflow-x-auto border-b border-slate-200 dark:border-slate-700">
<router-link
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-exact-active]:border-blue-500 [&.router-link-exact-active]:text-blue-500"
:to="{ name: 'project-overview' }"
>Overview</router-link>
<router-link
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
:to="{ name: 'documents' }"
>Documents</router-link>
<router-link
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
:to="{ name: 'tasks' }"
>Tasks</router-link>
<router-link
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
:to="{ name: 'members' }"
>Members</router-link>
</div>
<div class="flex h-full min-h-0 flex-col">
<nav class="mb-5 flex shrink-0 gap-1 overflow-x-auto border-b pb-px" style="border-color: var(--hairline)">
<router-link class="tab tab-exact" :to="{ name: 'project-overview' }">Overview</router-link>
<router-link class="tab" :to="{ name: 'documents' }">Documents</router-link>
<router-link class="tab" :to="{ name: 'tasks' }">Tasks</router-link>
<router-link class="tab" :to="{ name: 'members' }">Members</router-link>
</nav>
<div class="min-h-0 flex-1">
<router-view />
</div>
</div>
</template>
<style scoped>
.tab {
position: relative;
white-space: nowrap;
padding: 0.5rem 0.9rem 0.7rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--ink-muted);
transition: color 0.15s ease;
}
.tab:hover {
color: var(--ink);
}
.tab.router-link-active:not(.tab-exact),
.tab-exact.router-link-exact-active {
color: var(--primary);
font-weight: 600;
}
.app-dark .tab.router-link-active:not(.tab-exact),
.app-dark .tab-exact.router-link-exact-active {
color: #9ba6f8;
}
.tab.router-link-active:not(.tab-exact)::after,
.tab-exact.router-link-exact-active::after {
content: '';
position: absolute;
inset-inline: 0.4rem;
bottom: -1px;
height: 2px;
border-radius: 999px;
background: currentColor;
}
</style>
+2 -2
View File
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import Aura from '@primevue/themes/aura'
import KrakenPreset from './theme'
import 'primeicons/primeicons.css'
@@ -17,7 +17,7 @@ app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
preset: KrakenPreset,
options: { darkModeSelector: '.app-dark' },
},
})
+10 -7
View File
@@ -5,6 +5,7 @@ declare module 'vue-router' {
interface RouteMeta {
public?: boolean
screenKey?: string
title?: string
}
}
@@ -22,10 +23,9 @@ const router = createRouter({
component: () => import('../layouts/MainLayout.vue'),
children: [
{ path: '', redirect: '/projects' },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { screenKey: 'dashboard' } },
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects' } },
{ path: 'accounts', name: 'accounts', component: () => import('../views/AccountsView.vue'), meta: { screenKey: 'accounts' } },
{ path: 'roles', name: 'roles', component: () => import('../views/RolesView.vue'), meta: { screenKey: 'roles' } },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { screenKey: 'dashboard', title: 'Dashboard' } },
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects', title: 'Projects' } },
{ path: 'users', name: 'users', component: () => import('../views/users/UsersView.vue'), meta: { screenKey: 'users', title: 'Users' } },
{
path: 'projects/:id',
component: () => import('../layouts/ProjectLayout.vue'),
@@ -38,7 +38,8 @@ const router = createRouter({
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
],
},
{ path: 'settings', name: 'settings', component: () => import('../views/SettingsView.vue') },
{ path: 'settings', name: 'settings', component: () => import('../views/settings/SettingsView.vue'), meta: { title: 'Settings' } },
{ path: 'profile', name: 'profile', component: () => import('../views/profile/ProfileView.vue'), meta: { title: 'Profile' } },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/projects' },
@@ -51,13 +52,15 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { path: '/projects' }
return { path: '/dashboard' }
}
if (auth.isAuthenticated) {
await auth.ensureMenu()
}
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
return { path: '/projects' }
if (to.path !== '/dashboard') {
return { path: '/dashboard' }
}
}
})
+108 -28
View File
@@ -1,5 +1,19 @@
import { api } from './api'
import type { LoginResponse, Project, ProjectMember, MemberRole, Account, MenuItem, Role, SaveRoleRequest } from '../types'
import type {
LoginResponse,
Project,
ProjectMember,
MemberRole,
UserListItem,
MenuItem,
Role,
SaveRoleRequest,
MasterDataItem,
SaveMasterDataRequest,
PagedResult,
UserRoleDetail,
User,
} from '../types'
export async function login(username: string, password: string): Promise<LoginResponse> {
const { data } = await api.post<LoginResponse>('/api/auth/login', { username, password })
@@ -7,36 +21,65 @@ export async function login(username: string, password: string): Promise<LoginRe
}
export async function getMenu(): Promise<MenuItem[]> {
const { data } = await api.get<MenuItem[]>('/api/menu')
const { data } = await api.get<MenuItem[]>('/api/settings/permission/menu')
return data
}
export async function getRoles(): Promise<Role[]> {
const { data } = await api.get<Role[]>('/api/roles')
export async function getRoles(page = 1, pageSize = 20): Promise<PagedResult<Role>> {
const { data } = await api.get<PagedResult<Role>>('/api/settings/permission', { params: { page, pageSize } })
return data
}
export async function getRole(id: string): Promise<Role> {
const { data } = await api.get<Role>(`/api/roles/${id}`)
const { data } = await api.get<Role>(`/api/settings/permission/${id}`)
return data
}
export async function createRole(payload: SaveRoleRequest): Promise<Role> {
const { data } = await api.post<Role>('/api/roles', payload)
const { data } = await api.post<Role>('/api/settings/permission', payload)
return data
}
export async function updateRole(id: string, payload: SaveRoleRequest): Promise<Role> {
const { data } = await api.put<Role>(`/api/roles/${id}`, payload)
const { data } = await api.put<Role>(`/api/settings/permission/${id}`, payload)
return data
}
export async function deleteRole(id: string): Promise<void> {
await api.delete(`/api/roles/${id}`)
await api.delete(`/api/settings/permission/${id}`)
}
export async function getProjects(): Promise<Project[]> {
const { data } = await api.get<Project[]>('/api/projects')
export async function getMasterDataList(group?: string, page = 1, pageSize = 20): Promise<PagedResult<MasterDataItem>> {
const { data } = await api.get<PagedResult<MasterDataItem>>('/api/masterdata', { params: { group, page, pageSize } })
return data
}
export async function getMasterDataByGroup(group: string): Promise<MasterDataItem[]> {
const { data } = await api.get<MasterDataItem[]>(`/api/masterdata/groups/${group}`)
return data
}
export async function getMasterDataOptions(group: string): Promise<{ label: string; value: string }[]> {
const items = await getMasterDataByGroup(group)
return items.map((i) => ({ label: i.label, value: i.value }))
}
export async function createMasterData(payload: SaveMasterDataRequest): Promise<MasterDataItem> {
const { data } = await api.post<MasterDataItem>('/api/masterdata', payload)
return data
}
export async function updateMasterData(id: string, payload: SaveMasterDataRequest): Promise<MasterDataItem> {
const { data } = await api.put<MasterDataItem>(`/api/masterdata/${id}`, payload)
return data
}
export async function deleteMasterData(id: string): Promise<void> {
await api.delete(`/api/masterdata/${id}`)
}
export async function getProjects(page = 1, pageSize = 20): Promise<PagedResult<Project>> {
const { data } = await api.get<PagedResult<Project>>('/api/projects', { params: { page, pageSize } })
return data
}
@@ -72,8 +115,8 @@ export async function deleteProject(id: string): Promise<void> {
await api.delete(`/api/projects/${id}`)
}
export async function getMembers(projectId: string): Promise<ProjectMember[]> {
const { data } = await api.get<ProjectMember[]>(`/api/projects/${projectId}/members`)
export async function getMembers(projectId: string, page = 1, pageSize = 20): Promise<PagedResult<ProjectMember>> {
const { data } = await api.get<PagedResult<ProjectMember>>(`/api/projects/${projectId}/members`, { params: { page, pageSize } })
return data
}
@@ -90,38 +133,75 @@ export async function removeMember(projectId: string, userId: string): Promise<v
await api.delete(`/api/projects/${projectId}/members/${userId}`)
}
export async function getUsers(q?: string) {
const { data } = await api.get('/api/users', { params: { q } })
export async function updateMemberDocumentPermissions(
projectId: string,
userId: string,
payload: {
canViewDocuments: boolean
canCreateDocuments: boolean
canEditDocuments: boolean
canDeleteDocuments: boolean
},
): Promise<ProjectMember> {
const { data } = await api.put<ProjectMember>(`/api/projects/${projectId}/members/${userId}/document-permissions`, payload)
return data
}
export async function getAccounts(q?: string): Promise<Account[]> {
const { data } = await api.get<Account[]>('/api/accounts', { params: { q } })
export async function getProfile(): Promise<User> {
const { data } = await api.get<User>('/api/auth/me')
return data
}
export async function createAccount(payload: {
export async function getUsers(q?: string): Promise<User[]> {
const { data } = await api.get<User[]>('/api/users/list', { params: { q } })
return data
}
export async function getUsersPaged(q?: string, page = 1, pageSize = 20): Promise<PagedResult<UserListItem>> {
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params: { q, page, pageSize } })
return data
}
export async function createUser(payload: {
username: string
displayName: string
password: string
roleId: string
}): Promise<Account> {
const { data } = await api.post<Account>('/api/accounts', payload)
roleId?: string
}): Promise<UserListItem> {
const { data } = await api.post<UserListItem>('/api/users', payload)
return data
}
export async function updateAccount(
export async function updateUser(
id: string,
payload: { displayName: string; roleId: string; isActive: boolean },
): Promise<Account> {
const { data } = await api.put<Account>(`/api/accounts/${id}`, payload)
payload: { displayName: string; roleId?: string; isActive: boolean },
): Promise<UserListItem> {
const { data } = await api.put<UserListItem>(`/api/users/${id}`, payload)
return data
}
export async function deleteAccount(id: string): Promise<void> {
await api.delete(`/api/accounts/${id}`)
export async function deleteUser(id: string): Promise<void> {
await api.delete(`/api/users/${id}`)
}
export async function resetAccountPassword(id: string, newPassword: string): Promise<void> {
await api.post(`/api/accounts/${id}/reset-password`, { newPassword })
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
await api.post(`/api/users/${id}/reset-password`, { newPassword })
}
export async function getUserRoles(userId: string): Promise<UserRoleDetail> {
const { data } = await api.get<UserRoleDetail>(`/api/users/${userId}/roles`)
return data
}
export async function assignUserRole(userId: string, roleId: string): Promise<void> {
await api.post(`/api/users/${userId}/roles`, { roleId })
}
export async function unassignUserRole(userId: string, roleId: string): Promise<void> {
await api.delete(`/api/users/${userId}/roles/${roleId}`)
}
export async function getUserPermissions(userId: string): Promise<MenuItem[]> {
const { data } = await api.get<MenuItem[]>(`/api/users/${userId}/permissions`)
return data
}
+5 -3
View File
@@ -1,5 +1,5 @@
import { api } from './api'
import type { DocumentItem, DocumentNode, DocumentType, Task, TaskPriority, TaskStatus } from '../types'
import type { DocumentItem, DocumentNode, DocumentType, PagedResult, Task, TaskPriority, TaskStatus } from '../types'
export async function getDocumentTree(projectId: string): Promise<DocumentNode[]> {
const { data } = await api.get<DocumentNode[]>(`/api/projects/${projectId}/documents`)
@@ -44,8 +44,10 @@ export async function searchDocuments(q: string): Promise<DocumentNode[]> {
export async function getTasks(
projectId: string,
filters?: { status?: string; priority?: string; assigneeId?: string },
): Promise<Task[]> {
const { data } = await api.get<Task[]>(`/api/projects/${projectId}/tasks`, { params: filters })
page = 1,
pageSize = 20,
): Promise<PagedResult<Task>> {
const { data } = await api.get<PagedResult<Task>>(`/api/projects/${projectId}/tasks`, { params: { ...filters, page, pageSize } })
return data
}
+7 -1
View File
@@ -1,5 +1,5 @@
import type { MenuItem, User } from '../types'
import { login as apiLogin, getMenu } from '../services/backend'
import { login as apiLogin, getMenu, getProfile } from '../services/backend'
function loadUser(): User | null {
try {
@@ -41,6 +41,12 @@ export const useAuthStore = defineStore('auth', {
this.menu = await getMenu()
this.menuLoaded = true
},
async fetchProfile() {
if (this.token) {
this.user = await getProfile()
localStorage.setItem('mws_user', JSON.stringify(this.user))
}
},
async ensureMenu() {
if (!this.menuLoaded) await this.loadMenu()
},
+221 -32
View File
@@ -2,29 +2,166 @@
@custom-variant dark (&:where(.app-dark, .app-dark *));
@layer base {
body {
font-family: var(--font-family, Inter, 'Segoe UI', Roboto, Arial, sans-serif);
background: #f5f7fa;
color: #1e293b;
@theme {
/* Display = heavy geometric grotesque (DESIGN.md substitute for ABC Ginto Nord) */
--font-sans: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-display: 'Hanken Grotesk', 'Inter', Helvetica, Arial, sans-serif;
/* Blurple — brand primary, remaps every indigo-* utility */
--color-indigo-50: #eef0fe;
--color-indigo-100: #dfe3fd;
--color-indigo-200: #c3cafb;
--color-indigo-300: #9ba6f8;
--color-indigo-400: #7b88f5;
--color-indigo-500: #5865f2;
--color-indigo-600: #4551e0;
--color-indigo-700: #3742b8;
--color-indigo-800: #29328c;
--color-indigo-900: #1e2353;
--color-indigo-950: #0a0d3a;
/* Legacy purple-* aliases → Blurple, so untouched views follow the brand */
--color-purple-50: #eef0fe;
--color-purple-100: #dfe3fd;
--color-purple-200: #c3cafb;
--color-purple-300: #9ba6f8;
--color-purple-400: #7b88f5;
--color-purple-500: #5865f2;
--color-purple-600: #4551e0;
--color-purple-700: #3742b8;
--color-purple-800: #29328c;
--color-purple-900: #1e2353;
--color-purple-950: #0a0d3a;
/* Magenta — the playful counterweight (badges, folder marks, focus art) */
--color-fuchsia-400: #f26bcb;
--color-fuchsia-500: #ec48bd;
--color-fuchsia-600: #d32ba1;
/* Electric green — highest-intent only */
--color-green-50: #e8fdf1;
--color-green-100: #c7f9dd;
--color-green-200: #8df3bb;
--color-green-300: #56ef9c;
--color-green-400: #35ed7e;
--color-green-500: #1cc963;
--color-green-600: #14a151;
--color-green-700: #0f7c3f;
--color-green-800: #0b562d;
--color-green-900: #073a1f;
--color-green-950: #042313;
/* Cool indigo-tinted neutrals — remaps every slate-* utility */
--color-slate-50: #f6f7fb;
--color-slate-100: #eef0f7;
--color-slate-200: #dde0ed;
--color-slate-300: #c1c6dd;
--color-slate-400: #8e95b5;
--color-slate-500: #666d92;
--color-slate-600: #4a5177;
--color-slate-700: #2d3358;
--color-slate-800: #1e2353;
--color-slate-900: #141840;
--color-slate-950: #0a0d3a;
--shadow-subtle: rgba(69, 42, 124, 0.1) 0px 3px 34px;
--shadow-micro: rgba(20, 24, 64, 0.06) 0px 1px 3px;
}
.app-dark body {
background: #0f172a;
color: #e2e8f0;
:root {
--font-family: var(--font-sans);
--ink: #141840;
--ink-muted: #666d92;
--primary: #5865f2;
--magenta: #ec48bd;
--canvas: #f6f7fb;
--panel: #ffffff;
--hairline: #dde0ed;
--primary-soft: rgba(88, 101, 242, 0.12);
}
.app-dark {
--ink: #ffffff;
--ink-muted: #8e95b5;
--canvas: #0a0d3a;
--panel: #141840;
--hairline: #2d3358;
--primary-soft: rgba(123, 136, 245, 0.2);
}
@layer base {
body {
font-family: var(--font-family);
background: var(--canvas);
color: var(--ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#app {
height: 100vh;
height: 100dvh;
}
a {
color: #3b82f6;
color: var(--primary);
text-decoration: none;
}
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* CKEditor: fill its container, no fixed height */
.ck.ck-editor {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.ck.ck-editor__main {
display: flex;
min-height: 0;
flex: 1;
overflow: auto;
}
.ck-editor__editable {
min-height: 400px;
min-height: 100%;
}
.ck.ck-editor__editable_inline {
width: 100% !important;
padding: 20px !important;
border: 1px solid var(--hairline) !important;
border-radius: 0 0 12px 12px !important;
}
.ck.ck-toolbar {
border-radius: 0 !important;
}
.ck.ck-editor__editable_inline.ck-focused {
box-shadow: none !important;
}
/* No max-width here: CKEditor puts this class on the live editable too — capping
it would re-center and narrow the edit area. Read-view width lives on .document-content. */
.ck-content {
font-size: 16px;
line-height: 1.65;
}
.ck-content h2 {
font-family: var(--font-display);
font-weight: 700;
}
.ck-content h3,
.ck-content h4 {
font-family: var(--font-display);
font-weight: 600;
}
.ck-content ol,
@@ -37,26 +174,26 @@
}
.app-dark .ck.ck-editor {
--ck-color-base-background: #0f172a;
--ck-color-base-border: #334155;
--ck-color-base-text: #e2e8f0;
--ck-color-text: #e2e8f0;
--ck-color-focus-border: #3b82f6;
--ck-color-toolbar-background: #1e293b;
--ck-color-toolbar-border: #334155;
--ck-color-dropdown-panel-background: #1e293b;
--ck-color-panel-background: #1e293b;
--ck-color-panel-border: #334155;
--ck-color-button-default-hover-background: #334155;
--ck-color-button-default-active-background: #334155;
--ck-color-button-on-background: #334155;
--ck-color-input-background: #0f172a;
--ck-color-input-border: #334155;
--ck-color-input-text: #e2e8f0;
--ck-color-tooltip-background: #334155;
--ck-color-tooltip-text: #e2e8f0;
--ck-color-table-border: #475569;
--ck-color-link-default: #60a5fa;
--ck-color-base-background: #0a0d3a;
--ck-color-base-border: #2d3358;
--ck-color-base-text: #ffffff;
--ck-color-text: #ffffff;
--ck-color-focus-border: #5865f2;
--ck-color-toolbar-background: #141840;
--ck-color-toolbar-border: #2d3358;
--ck-color-dropdown-panel-background: #141840;
--ck-color-panel-background: #141840;
--ck-color-panel-border: #2d3358;
--ck-color-button-default-hover-background: #2d3358;
--ck-color-button-default-active-background: #2d3358;
--ck-color-button-on-background: #2d3358;
--ck-color-input-background: #0a0d3a;
--ck-color-input-border: #2d3358;
--ck-color-input-text: #ffffff;
--ck-color-tooltip-background: #2d3358;
--ck-color-tooltip-text: #ffffff;
--ck-color-table-border: #4a5177;
--ck-color-link-default: #9ba6f8;
}
}
@@ -66,6 +203,58 @@
}
.field > label {
@apply mb-1.5 block text-sm font-medium;
@apply mb-1.5 block text-[13px] font-semibold uppercase tracking-[0.06em];
color: var(--ink-muted);
}
.page-title {
font-family: var(--font-display);
font-size: clamp(28px, 3vw, 38px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--ink);
}
.page-subtitle {
font-size: 15px;
line-height: 1.5;
color: var(--ink-muted);
}
.muted-note {
font-size: 13px;
line-height: 1.45;
color: var(--ink-muted);
}
/* Eyebrow: small caps label above a title */
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
/* Surface card used outside PrimeVue <Card> */
.panel {
background: var(--panel);
border: 1px solid var(--hairline);
border-radius: 16px;
}
.search-input.p-inputtext,
.search-input.p-select {
border-radius: 12px !important;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+219
View File
@@ -0,0 +1,219 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
// Blurple — DESIGN.md brand primary (#5865f2)
const blurple = {
50: '#eef0fe',
100: '#dfe3fd',
200: '#c3cafb',
300: '#9ba6f8',
400: '#7b88f5',
500: '#5865f2',
600: '#4551e0',
700: '#3742b8',
800: '#29328c',
900: '#1e2353',
950: '#0a0d3a',
}
const green = {
50: '#e8fdf1',
100: '#c7f9dd',
200: '#8df3bb',
300: '#56ef9c',
400: '#35ed7e',
500: '#1cc963',
600: '#14a151',
700: '#0f7c3f',
800: '#0b562d',
900: '#073a1f',
950: '#042313',
}
const lightSurface = {
0: '#ffffff',
50: '#f6f7fb',
100: '#eef0f7',
200: '#dde0ed',
300: '#c1c6dd',
400: '#8e95b5',
500: '#666d92',
600: '#4a5177',
700: '#2d3358',
800: '#1e2353',
900: '#141840',
950: '#0a0d3a',
}
const darkSurface = {
0: '#ffffff',
50: '#f6f7fb',
100: '#dde0ed',
200: '#c1c6dd',
300: '#8e95b5',
400: '#666d92',
500: '#4a5177',
600: '#2d3358',
700: '#242a4d',
800: '#1e2353',
900: '#141840',
950: '#0a0d3a',
}
export default definePreset(Aura, {
primitive: {
borderRadius: {
none: '0',
xs: '6px',
sm: '10px',
md: '12px',
lg: '16px',
xl: '20px',
},
blurple,
green,
},
semantic: {
primary: blurple,
success: green,
focusRing: {
width: '2px',
style: 'solid',
color: '{primary.500}',
offset: '2px',
shadow: 'none',
},
colorScheme: {
light: {
surface: lightSurface,
primary: {
color: '{primary.500}',
contrastColor: '#ffffff',
hoverColor: '{primary.600}',
activeColor: '{primary.700}',
},
text: {
color: '{surface.900}',
hoverColor: '{surface.950}',
mutedColor: '{surface.500}',
hoverMutedColor: '{surface.600}',
},
formField: {
borderColor: '{surface.200}',
hoverBorderColor: '{surface.300}',
focusBorderColor: '{primary.500}',
color: '{surface.900}',
},
content: {
background: '#ffffff',
borderColor: '{surface.200}',
},
overlay: {
modal: {
background: '#ffffff',
borderColor: '{surface.200}',
color: '{text.color}',
shadow: 'rgba(69, 42, 124, 0.14) 0px 12px 48px',
},
},
},
dark: {
surface: darkSurface,
text: {
color: '#ffffff',
hoverColor: '#ffffff',
mutedColor: '{surface.300}',
hoverMutedColor: '{surface.200}',
},
primary: {
color: '{primary.400}',
contrastColor: '#0a0d3a',
hoverColor: '{primary.300}',
activeColor: '{primary.200}',
},
content: {
background: '#141840',
borderColor: '{surface.600}',
},
overlay: {
modal: {
background: '#141840',
borderColor: '{surface.600}',
color: '#ffffff',
shadow: 'rgba(0, 0, 0, 0.5) 0px 12px 48px',
},
},
},
},
},
components: {
button: {
root: {
borderRadius: '12px',
paddingX: '1.05rem',
label: { fontWeight: '600' },
},
},
card: {
root: {
background: '{content.background}',
borderRadius: '16px',
border: '1px solid {content.border.color}',
shadow: 'none',
},
body: { gap: '0.75rem' },
title: { fontSize: '0.95rem', fontWeight: '700' },
},
dialog: {
root: { borderRadius: '16px' },
header: { padding: '1.25rem 1.5rem 0.75rem' },
content: { padding: '0 1.5rem 0.5rem' },
footer: { padding: '0.75rem 1.5rem 1.25rem' },
title: { fontWeight: '700', fontSize: '1.05rem' },
},
datatable: {
header: {
background: '{content.background}',
borderColor: '{content.border.color}',
color: '{text.muted.color}',
},
headerCell: {
background: 'transparent',
borderColor: '{content.border.color}',
color: '{text.muted.color}',
fontWeight: '600',
padding: '0.7rem 1rem',
},
bodyCell: { padding: '0.8rem 1rem' },
row: { borderColor: '{content.border.color}' },
},
treetable: {
headerCell: {
background: 'transparent',
borderColor: '{content.border.color}',
color: '{text.muted.color}',
fontWeight: '600',
},
bodyCell: { padding: '0.55rem 0.85rem' },
row: { borderColor: '{content.border.color}' },
},
tag: {
root: { borderRadius: '999px', fontWeight: '600', padding: '0.2rem 0.6rem' },
},
inputtext: {
root: { borderRadius: '12px' },
},
select: {
root: { borderRadius: '12px' },
},
textarea: {
root: { borderRadius: '12px' },
},
toast: {
root: { borderRadius: '16px' },
},
menu: {
root: { borderRadius: '14px' },
},
},
})
+44 -1
View File
@@ -1,3 +1,10 @@
export interface PagedResult<T> {
items: T[]
totalCount: number
page: number
pageSize: number
}
export interface User {
id: string
username: string
@@ -6,7 +13,7 @@ export interface User {
roleName: string
}
export interface Account {
export interface UserListItem {
id: string
username: string
displayName: string
@@ -51,6 +58,23 @@ export interface SaveRoleRequest {
permissions: PermissionEntry[]
}
export interface MasterDataItem {
id: string
group: string
label: string
value: string
sortOrder: number
isActive: boolean
}
export interface SaveMasterDataRequest {
group: string
label: string
value: string
sortOrder: number
isActive: boolean
}
export type ProjectStatus = 'Active' | 'Archived'
export type MemberRole = 'Owner' | 'Member'
export type DocumentType = 'Folder' | 'Document'
@@ -62,7 +86,11 @@ export interface Project {
name: string
description: string | null
status: ProjectStatus
createdBy: string
createdByName: string
createdAt: string
updatedBy: string | null
updatedByName: string | null
updatedAt: string
}
@@ -71,6 +99,10 @@ export interface ProjectMember {
username: string
displayName: string
role: MemberRole
canViewDocuments: boolean
canCreateDocuments: boolean
canEditDocuments: boolean
canDeleteDocuments: boolean
}
export interface ProjectOverview {
@@ -100,7 +132,10 @@ export interface DocumentNode {
parentId: string | null
title: string
type: DocumentType
createdAt: string
createdBy: string
updatedAt: string
updatedBy: string | null
children: DocumentNode[]
}
@@ -131,6 +166,14 @@ export interface Task {
updatedAt: string
}
export interface UserRoleDetail {
userId: string
username: string
displayName: string
assignedRoles: Role[]
unassignedRoles: Role[]
}
export interface TaskCounts {
[status: string]: number
}
-100
View File
@@ -1,100 +0,0 @@
<template>
<div>
<div class="mb-6">
<h1 class="m-0 text-2xl font-semibold">Dashboard</h1>
<p class="mt-1 text-slate-500 dark:text-slate-400">
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}.
</p>
</div>
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Projects</div>
<div class="text-[1.8rem] font-bold">{{ projects.length }}</div>
</template>
</Card>
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Active</div>
<div class="text-[1.8rem] font-bold">{{ activeCount }}</div>
</template>
</Card>
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Archived</div>
<div class="text-[1.8rem] font-bold">{{ archivedCount }}</div>
</template>
</Card>
</div>
<Card class="mb-6">
<template #title>Recent Projects</template>
<template #content>
<div class="overflow-x-auto">
<DataTable :value="recentProjects" :loading="loading" emptyMessage="No projects yet">
<Column field="name" header="Name">
<template #body="{ data }">
<router-link
:to="{ name: 'project-overview', params: { id: data.id } }"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ data.name }}
</router-link>
</template>
</Column>
<Column field="status" header="Status" style="width: 140px">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 160px">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>
</Card>
<div class="flex flex-wrap gap-2">
<Button icon="pi pi-plus" label="New Project" :to="{ name: 'projects' }" />
<Button icon="pi pi-cog" label="Settings" outlined :to="{ name: 'settings' }" />
</div>
</div>
</template>
<script setup lang="ts">
import { getProjects } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { Project } from '../types'
const auth = useAuthStore()
const toast = useToast()
const projects = ref<Project[]>([])
const loading = ref(false)
const activeCount = computed(() => projects.value.filter((p) => p.status === 'Active').length)
const archivedCount = computed(() => projects.value.filter((p) => p.status === 'Archived').length)
const recentProjects = computed(() => projects.value.slice(0, 8))
async function load() {
loading.value = true
try {
projects.value = await getProjects()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
-164
View File
@@ -1,164 +0,0 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Roles</h1>
<Button v-if="auth.can('roles', 'create')" label="New Role" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="overflow-x-auto">
<DataTable :value="roles" :loading="loading" emptyMessage="No roles" class="min-w-[480px]">
<Column field="name" header="Name" style="width: 40%" />
<Column header="Type" style="width: 20%">
<template #body="{ data }">
<Tag :value="data.isSystem ? 'System' : 'Custom'" :severity="data.isSystem ? 'warn' : 'secondary'" />
</template>
</Column>
<Column header="" style="width: 40%">
<template #body="{ data }">
<div class="flex justify-end gap-1">
<Button v-if="auth.can('roles', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button
v-if="auth.can('roles', 'delete')"
icon="pi pi-trash"
text
severity="danger"
:disabled="data.isSystem"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Role' : 'New Role'" :modal="true" style="width: min(640px, 92vw)">
<div class="field">
<label for="role-name">Name</label>
<InputText id="role-name" v-model.trim="form.name" class="w-full" autofocus />
</div>
<div class="field">
<label>Permissions</label>
<div class="overflow-x-auto">
<table class="w-full min-w-[420px] border-collapse text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="py-2 text-left font-medium">Screen</th>
<th class="w-16 text-center font-medium">View</th>
<th class="w-16 text-center font-medium">Create</th>
<th class="w-16 text-center font-medium">Edit</th>
<th class="w-16 text-center font-medium">Delete</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.permissions" :key="row.screen" class="border-b border-slate-100 dark:border-slate-800">
<td class="py-2">{{ screenLabel(row.screen) }}</td>
<td class="text-center"><Checkbox v-model="row.canView" binary /></td>
<td class="text-center"><Checkbox v-model="row.canCreate" binary /></td>
<td class="text-center"><Checkbox v-model="row.canEdit" binary /></td>
<td class="text-center"><Checkbox v-model="row.canDelete" binary /></td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getRoles, createRole, updateRole, deleteRole } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { PermissionEntry, Role } from '../types'
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const roles = ref<Role[]>([])
const loading = ref(false)
const saving = ref(false)
const formDialog = ref(false)
const editTarget = ref<Role | null>(null)
const form = ref<{ name: string; permissions: PermissionEntry[] }>({ name: '', permissions: [] })
function emptyPermissions(): PermissionEntry[] {
return auth.menu.map((m) => ({ screen: m.key, canView: false, canCreate: false, canEdit: false, canDelete: false }))
}
function screenLabel(key: string) {
return auth.menu.find((m) => m.key === key)?.label ?? key
}
async function loadRoles() {
loading.value = true
try {
roles.value = await getRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function openCreate() {
editTarget.value = null
form.value = { name: '', permissions: emptyPermissions() }
formDialog.value = true
}
function openEdit(role: Role) {
editTarget.value = role
const permissions = emptyPermissions().map((row) => {
const existing = role.permissions.find((p) => p.screen === row.screen)
return existing ? { ...existing } : row
})
form.value = { name: role.name, permissions }
formDialog.value = true
}
async function onSave() {
if (!form.value.name) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
saving.value = true
try {
if (editTarget.value) {
await updateRole(editTarget.value.id, form.value)
} else {
await createRole(form.value)
}
formDialog.value = false
toast.add({ severity: 'success', summary: 'Role saved', life: 3000 })
await loadRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmDelete(role: Role) {
confirm.require({
message: `Delete role "${role.name}"?`,
header: 'Delete',
accept: async () => {
try {
await deleteRole(role.id)
toast.add({ severity: 'success', summary: 'Role deleted', life: 2000 })
await loadRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
onMounted(loadRoles)
</script>
-64
View File
@@ -1,64 +0,0 @@
<template>
<div class="max-w-xl">
<div class="mb-6">
<h1 class="m-0 text-2xl font-semibold">Settings</h1>
</div>
<Card class="mb-4">
<template #title>Profile</template>
<template #content>
<div class="flex items-center gap-3">
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="large" />
<div>
<div class="font-semibold">{{ auth.user?.displayName }}</div>
<div class="text-sm text-slate-500 dark:text-slate-400">@{{ auth.user?.username }}</div>
</div>
</div>
</template>
</Card>
<Card class="mb-4">
<template #title>Appearance</template>
<template #content>
<div class="flex items-center justify-between gap-3">
<div>
<div class="font-medium">Dark mode</div>
<div class="text-sm text-slate-500 dark:text-slate-400">Switch between light and dark theme</div>
</div>
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
:label="theme.isDark.value ? 'Light' : 'Dark'"
severity="secondary"
outlined
@click="theme.toggle"
/>
</div>
</template>
</Card>
<Card>
<template #title>Account</template>
<template #content>
<Button icon="pi pi-sign-out" label="Logout" severity="danger" outlined @click="onLogout" />
</template>
</Card>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme'
const auth = useAuthStore()
const theme = useTheme()
const initials = computed(() => {
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
return name.slice(0, 2).toUpperCase()
})
function onLogout() {
auth.logout()
window.location.href = '/login'
}
</script>
+48 -16
View File
@@ -1,41 +1,63 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-4 dark:bg-slate-900">
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
rounded
text
style="position: fixed; right: 1rem; top: 1rem; z-index: 10"
severity="secondary"
class="!fixed !right-4 !top-4 z-10"
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggle"
/>
<Card class="w-full max-w-[380px]">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-briefcase" style="color: #3b82f6"></i>
MWS My Workspace
<!-- Signature: the indigo canvas with the brand gradient mesh -->
<section class="brand-panel relative hidden overflow-hidden p-12 lg:flex lg:flex-col lg:justify-between">
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em] text-white">Workspace</span>
<div class="relative z-10 max-w-[440px]">
<h1 class="font-display text-[clamp(40px,4.4vw,62px)] font-extrabold uppercase leading-[0.98] text-white">
Projects,<br />docs, tasks.<br />One place.
</h1>
<p class="mt-5 text-[17px] leading-relaxed text-white/70">
Write documents, track work, and keep your team in sync without switching tools.
</p>
</div>
</template>
<template #content>
<div class="relative z-10 flex gap-6 text-white/60">
<span class="text-[13px]">Documents</span>
<span class="text-[13px]">Tasks</span>
<span class="text-[13px]">Members</span>
</div>
</section>
<section class="flex items-center justify-center p-6" style="background: var(--canvas)">
<div class="w-full max-w-[360px]">
<div class="mb-7">
<span class="eyebrow">My Workspace</span>
<h2 class="page-title mt-2">Sign in</h2>
<p class="page-subtitle mt-1.5">Use the account your workspace owner set up for you.</p>
</div>
<form @submit.prevent="submit">
<div class="field">
<label for="username">Username</label>
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" />
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">Password</label>
<InputText
<Password
id="password"
v-model="password"
type="password"
class="w-full"
inputClass="w-full"
toggleMask
:feedback="false"
autocomplete="current-password"
/>
</div>
<Message v-if="error" severity="error" variant="simple" class="mb-2 w-full">{{ error }}</Message>
<Message v-if="error" severity="error" variant="simple" class="mb-3 w-full">{{ error }}</Message>
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
</form>
</template>
</Card>
</div>
</section>
</div>
</template>
@@ -57,7 +79,7 @@ const error = ref('')
async function submit() {
error.value = ''
if (!username.value || !password.value) {
error.value = 'Username and password are required'
error.value = 'Enter your username and password'
return
}
loading.value = true
@@ -72,3 +94,13 @@ async function submit() {
}
}
</script>
<style scoped>
/* Blurple → magenta mesh over the deep-indigo canvas (DESIGN.md brand gradient) */
.brand-panel {
background:
radial-gradient(120% 90% at 12% 8%, rgba(88, 101, 242, 0.85) 0%, transparent 58%),
radial-gradient(95% 85% at 88% 92%, rgba(236, 72, 189, 0.6) 0%, transparent 62%),
#0a0d3a;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div>
<div class="mb-6 grid gap-3 sm:grid-cols-3">
<div v-for="stat in stats" :key="stat.label" class="panel px-5 py-4">
<div class="muted-note">{{ stat.label }}</div>
<div class="mt-1 font-display text-[34px] font-extrabold leading-none" style="color: var(--ink)">
{{ stat.value }}
</div>
</div>
</div>
<section class="panel mb-6 overflow-hidden">
<div class="flex items-center justify-between border-b px-5 py-3.5" style="border-color: var(--hairline)">
<h2 class="font-display text-[15px] font-bold" style="color: var(--ink)">Recent projects</h2>
<Button label="All projects" icon="pi pi-arrow-right" iconPos="right" text size="small" @click="router.push({ name: 'projects' })" />
</div>
<DataTable :value="recentProjects" :loading="loading" class="min-w-0">
<template #empty>
<div class="grid place-items-center gap-2 px-4 py-12 text-center">
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
<p class="muted-note">Create a project to start collecting documents and tasks.</p>
<Button label="New project" icon="pi pi-plus" size="small" class="mt-1" @click="router.push({ name: 'projects' })" />
</div>
</template>
<Column field="name" header="Name">
<template #body="{ data }">
<router-link
:to="{ name: 'project-overview', params: { id: data.id } }"
class="font-semibold hover:underline"
>
{{ data.name }}
</router-link>
</template>
</Column>
<Column field="status" header="Status" style="width: 140px">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 150px">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</section>
</div>
</template>
<script setup lang="ts">
import { getProjects } from '../../services/backend'
import { errorMessage } from '../../services/api'
import type { Project } from '../../types'
const toast = useToast()
const router = useRouter()
const projects = ref<Project[]>([])
const loading = ref(false)
const stats = computed(() => [
{ label: 'Projects', value: projects.value.length },
{ label: 'Active', value: projects.value.filter((p) => p.status === 'Active').length },
{ label: 'Archived', value: projects.value.filter((p) => p.status === 'Archived').length },
])
const recentProjects = computed(() => projects.value.slice(0, 8))
async function load() {
loading.value = true
try {
const res = await getProjects(1, 100)
projects.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
+88 -86
View File
@@ -1,60 +1,36 @@
<template>
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
<SplitterPanel :size="30" :minSize="20">
<div class="h-full min-h-[420px]">
<DocumentTreePanel
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
:can-create="canCreate"
:members="members"
@select="selectDocument"
@create="openCreate"
/>
</SplitterPanel>
<SplitterPanel>
<DocumentEditorPanel
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
/>
</SplitterPanel>
</Splitter>
<div v-else>
<DocumentTreePanel
v-if="!doc"
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
@select="selectDocument"
@create="openCreate"
/>
<DocumentEditorPanel
v-else
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
@back="closeDocument"
/>
</div>
<DocumentViewerModal
:visible="viewerVisible"
:doc="doc"
:model-value="contentModel"
:editing="editing"
:save-state="saveState"
:can-edit="canEdit"
:can-delete="canDelete"
@update:visible="onViewerClose"
@update:model-value="onContentChange"
@edit="startEdit"
@cancel-edit="cancelEdit"
@save="onSave"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
/>
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label>Title</label>
@@ -95,7 +71,7 @@
<script setup lang="ts">
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
import DocumentViewerModal from '../../components/DocumentViewerModal.vue'
import {
getDocumentTree,
getDocument,
@@ -105,9 +81,10 @@ import {
deleteDocument,
searchDocuments,
} from '../../services/modules'
import { getMembers } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
import type { DocumentItem, DocumentNode, DocumentType, ProjectMember } from '../../types'
const route = useRoute()
const toast = useToast()
@@ -121,6 +98,10 @@ const selectedId = ref<string | null>(null)
const doc = ref<DocumentItem | null>(null)
const contentModel = ref('')
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
const editing = ref(false)
const viewerVisible = ref(false)
const members = ref<ProjectMember[]>([])
const searchTerm = ref('')
const searchMode = ref(false)
@@ -133,14 +114,15 @@ const renameTitle = ref('')
const moveDialog = ref(false)
const moveTarget = ref<string | null>(null)
const isDesktop = ref(false)
let mediaQuery: MediaQueryList | null = null
let saveTimer: ReturnType<typeof setTimeout> | undefined
let searchTimer: ReturnType<typeof setTimeout> | undefined
const currentMember = computed(() => members.value.find((m) => m.userId === auth.user?.id))
const canCreate = computed(() => currentMember.value?.canCreateDocuments ?? false)
const canEdit = computed(() => currentMember.value?.canEditDocuments ?? false)
const canDelete = computed(() => currentMember.value?.canDeleteDocuments ?? false)
const creatingLabel = computed(() =>
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
doc.value?.type === 'Folder' ? `New items go inside "${doc.value.title}"` : 'New items are created at the root',
)
const folderOptions = computed(() => {
@@ -163,8 +145,15 @@ async function loadTree() {
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
async function loadMembers() {
try {
const res = await getMembers(projectId, 1, 100)
members.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
@@ -178,19 +167,55 @@ watch(searchTerm, () => {
async function selectDocument(id: string) {
selectedId.value = id
clearTimeout(saveTimer)
try {
doc.value = await getDocument(id)
contentModel.value = doc.value.content ?? ''
editing.value = false
saveState.value = 'idle'
viewerVisible.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function closeDocument() {
doc.value = null
selectedId.value = null
function onViewerClose(value: boolean) {
viewerVisible.value = value
if (!value) {
editing.value = false
}
}
function startEdit() {
editing.value = true
saveState.value = 'idle'
}
function cancelEdit() {
editing.value = false
contentModel.value = doc.value?.content ?? ''
saveState.value = 'idle'
}
function onContentChange(value: string) {
contentModel.value = value
saveState.value = 'idle'
}
async function onSave() {
if (!doc.value) return
saveState.value = 'saving'
try {
const updated = await updateDocument(doc.value.id, { title: doc.value.title, content: contentModel.value })
doc.value = updated
contentModel.value = updated.content ?? ''
editing.value = false
saveState.value = 'saved'
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
await loadTree()
} catch (e) {
saveState.value = 'idle'
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 4000 })
}
}
function openCreate(type: DocumentType) {
@@ -264,11 +289,14 @@ function confirmDelete() {
confirm.require({
message: `Delete "${doc.value.title}"?`,
header: 'Delete',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteDocument(doc.value!.id)
doc.value = null
selectedId.value = null
viewerVisible.value = false
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
await loadTree()
} catch (e) {
@@ -278,36 +306,10 @@ function confirmDelete() {
})
}
function onContentChange(value: string) {
if (!doc.value) return
contentModel.value = value
saveState.value = 'saving'
clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
try {
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
doc.value = updated
saveState.value = 'saved'
} catch (e) {
saveState.value = 'idle'
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
}
}, 800)
}
function syncDesktop() {
isDesktop.value = mediaQuery?.matches ?? false
}
onMounted(() => {
mediaQuery = window.matchMedia('(min-width: 1024px)')
syncDesktop()
mediaQuery.addEventListener('change', syncDesktop)
loading.value = true
void loadTree()
Promise.all([loadTree(), loadMembers()]).finally(() => {
loading.value = false
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', syncDesktop)
})
</script>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div>
<div class="mb-6">
<h1 class="page-title m-0">Profile</h1>
</div>
<div class="panel max-w-[480px] p-5">
<div class="flex items-center gap-3 mb-5">
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
<div>
<div class="text-[15px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</div>
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
</div>
</div>
<form @submit.prevent="onSave">
<div class="field">
<label for="prof-username">Username</label>
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-displayname">Display name</label>
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
</div>
<div class="field">
<label for="prof-role">Role</label>
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-status">Status</label>
<InputText id="prof-status" value="Active" disabled class="w-full" />
</div>
<div class="mt-5 flex justify-end">
<Button type="submit" label="Save" :loading="saving" />
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { updateUser } from '../../services/backend'
import { errorMessage } from '../../services/api'
const toast = useToast()
const auth = useAuthStore()
onMounted(() => {
auth.fetchProfile()
})
const displayName = ref(auth.user?.displayName ?? '')
const saving = ref(false)
watch(
() => auth.user?.displayName,
(val) => {
if (val !== undefined) displayName.value = val
}
)
const initials = computed(() => {
const name = displayName.value || auth.user?.username || '?'
return name.slice(0, 2).toUpperCase()
})
async function onSave() {
if (!auth.user?.id) return
if (!displayName.value) {
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
return
}
saving.value = true
try {
const updated = await updateUser(auth.user.id, {
displayName: displayName.value,
isActive: true,
})
if (auth.user) {
auth.user.displayName = updated.displayName
localStorage.setItem('mws_user', JSON.stringify(auth.user))
}
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
</script>
+132 -17
View File
@@ -1,39 +1,82 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
<div class="flex h-full flex-col">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="sm:w-[320px]">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model.trim="userSearch" placeholder="Search users..." class="search-input w-full" @input="debouncedUsers" />
</IconField>
</div>
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
</div>
<div class="overflow-x-auto">
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
<AppDataTable
:value="members"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
emptyMessage="No members yet"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[480px]"
>
<Column header="User" style="width: 50%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
style="background: #3b82f6; color: #fff" />
style="background: var(--primary); color: #fff" />
<span>{{ data.displayName }}</span>
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
<span class="muted-note">@{{ data.username }}</span>
</div>
</template>
</Column>
<Column field="role" header="Role" style="width: 20%">
<Column field="role" header="Role" style="width: 16%">
<template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" />
</template>
</Column>
<Column header="Document Access" style="width: 34%">
<template #body="{ data }">
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1">
<Tag value="Full Access" severity="primary" />
</div>
<div v-else class="flex flex-wrap gap-1">
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
<Tag v-if="data.canCreateDocuments" value="Create" severity="secondary" />
<Tag v-if="data.canEditDocuments" value="Edit" severity="secondary" />
<Tag v-if="data.canDeleteDocuments" value="Delete" severity="secondary" />
<Tag v-if="!data.canViewDocuments" value="No access" severity="danger" />
</div>
</template>
</Column>
<Column header="" style="width: 10%">
<template #body="{ data }">
<div v-if="isOwner && data.role !== 'Owner'" class="flex justify-end gap-1">
<Button
icon="pi pi-sliders-h"
text
severity="secondary"
:aria-label="`Set document permissions for ${data.displayName}`"
@click="openPermissions(data)"
/>
<Button
v-if="isOwner && data.role !== 'Owner'"
icon="pi pi-trash"
text
severity="danger"
:aria-label="`Remove ${data.displayName}`"
@click="onRemove(data.userId)"
/>
</div>
</template>
</Column>
</DataTable>
</AppDataTable>
</div>
</div>
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
@@ -53,14 +96,40 @@
<Button label="Add" :loading="adding" @click="onAdd" />
</template>
</Dialog>
<Dialog v-model:visible="permDialog" header="Document Access" :modal="true" style="width: min(440px, 92vw)">
<div class="muted-note mb-3">
Permissions for <span class="font-medium" style="color: var(--ink)">{{ permTarget?.displayName }}</span>
@{{ permTarget?.username }}
</div>
<div class="flex flex-col gap-3">
<label
v-for="opt in permOptions"
:key="opt.key"
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2" style="border-color: var(--hairline)"
>
<Checkbox v-model="permForm[opt.key]" binary />
<div>
<div class="text-sm font-medium">{{ opt.label }}</div>
<div class="muted-note">{{ opt.hint }}</div>
</div>
</label>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="permDialog = false" />
<Button label="Save" :loading="savingPerms" @click="savePermissions" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPermissions, getMasterDataOptions } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { ProjectMember } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const route = useRoute()
const toast = useToast()
@@ -71,17 +140,30 @@ const members = ref<ProjectMember[]>([])
const loading = ref(false)
const isOwner = ref(false)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const addDialog = ref(false)
const userSearch = ref('')
const users = ref<{ label: string; value: string }[]>([])
const selectedUserId = ref<string | null>(null)
const newRole = ref<'Owner' | 'Member'>('Member')
const roleOptions = [
{ label: 'Member', value: 'Member' },
{ label: 'Owner', value: 'Owner' },
]
const roleOptions = ref<{ label: string; value: string }[]>([])
const adding = ref(false)
const permDialog = ref(false)
const permTarget = ref<ProjectMember | null>(null)
const permForm = reactive({ canViewDocuments: false, canCreateDocuments: false, canEditDocuments: false, canDeleteDocuments: false })
const savingPerms = ref(false)
const permOptions = [
{ key: 'canViewDocuments' as const, label: 'View', hint: 'See documents in the project' },
{ key: 'canCreateDocuments' as const, label: 'Create', hint: 'Create new documents and folders' },
{ key: 'canEditDocuments' as const, label: 'Edit', hint: 'Edit content, rename and move' },
{ key: 'canDeleteDocuments' as const, label: 'Delete', hint: 'Delete documents and folders' },
]
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
let timer: ReturnType<typeof setTimeout> | undefined
@@ -89,7 +171,9 @@ let timer: ReturnType<typeof setTimeout> | undefined
async function loadMembers() {
loading.value = true
try {
members.value = await getMembers(projectId)
const res = await getMembers(projectId, page.value, pageSize.value)
members.value = res.items
totalCount.value = res.totalCount
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
@@ -98,6 +182,12 @@ async function loadMembers() {
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void loadMembers()
}
async function loadUsers() {
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
label: `${u.displayName} (@${u.username})`,
@@ -139,7 +229,32 @@ async function onRemove(userId: string) {
}
}
function openPermissions(member: ProjectMember) {
permTarget.value = member
permForm.canViewDocuments = member.canViewDocuments
permForm.canCreateDocuments = member.canCreateDocuments
permForm.canEditDocuments = member.canEditDocuments
permForm.canDeleteDocuments = member.canDeleteDocuments
permDialog.value = true
}
async function savePermissions() {
if (!permTarget.value) return
savingPerms.value = true
try {
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm })
permDialog.value = false
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
await loadMembers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
savingPerms.value = false
}
}
onMounted(async () => {
roleOptions.value = await getMasterDataOptions('member_role')
await loadMembers()
await loadUsers()
})
+31 -40
View File
@@ -1,38 +1,30 @@
<template>
<div v-if="overview">
<div class="mb-4 flex items-center gap-3">
<h1 class="m-0 text-2xl font-bold">{{ overview.project.name }}</h1>
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warning" />
</div>
<p v-if="overview.project.description" class="-mt-3 mb-4 text-slate-500 dark:text-slate-400">{{ overview.project.description }}</p>
<div class="mb-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Members</div>
<div class="text-[1.8rem] font-bold">{{ overview.memberCount }}</div>
</template>
</Card>
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Documents</div>
<div class="text-[1.8rem] font-bold">{{ overview.documentCount }}</div>
</template>
</Card>
<Card v-for="(count, status) in taskCounts" :key="status" class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">{{ statusLabel(status) }}</div>
<div class="text-[1.8rem] font-bold">{{ count }}</div>
</template>
</Card>
<div class="mb-6 flex items-center gap-3">
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
</div>
<div class="mb-3 flex flex-col gap-3 lg:flex-row">
<Card class="min-w-0 flex-1">
<template #title>Recent Tasks</template>
<template #content>
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Members</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
</div>
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Documents</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
</div>
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
</div>
</div>
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentTasks" emptyMessage="No tasks">
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
<Column field="title" header="Title" />
<Column field="status" header="Status" style="width: 120px">
<template #body="{ data }">
@@ -41,23 +33,20 @@
</Column>
</DataTable>
</div>
</template>
</Card>
<Card class="min-w-0 flex-1">
<template #title>Recent Documents</template>
<template #content>
</div>
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentDocuments" emptyMessage="No documents">
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
<Column field="title" header="Title" />
<Column header="Updated" style="width: 150px">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>
</Card>
</div>
</div>
<div class="flex flex-wrap gap-2">
@@ -66,7 +55,7 @@
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
icon="pi pi-archive"
label="Archive project"
severity="warning"
severity="warn"
outlined
@click="confirmArchive"
/>
@@ -167,6 +156,8 @@ function confirmArchive() {
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Archive',
rejectLabel: 'Cancel',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteProject(overview.value!.project.id)
+84 -24
View File
@@ -1,47 +1,86 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Projects</h1>
<div class="flex h-full flex-col">
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="page-title m-0">Projects</h1>
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
</div>
</header>
<div class="mb-3">
<div class="mb-4 sm:w-[320px]">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model.trim="searchTerm"
placeholder="Search projects..."
icon="pi pi-search"
class="w-full sm:w-[320px]"
class="search-input w-full"
@input="debouncedSearch"
/>
</IconField>
</div>
<div class="overflow-x-auto">
<DataTable :value="projects" :loading="loading" v-model:selection="selectedProject" selectionMode="single"
dataKey="id" @row-select="onRowSelect" emptyMessage="No projects found" class="min-w-[600px]">
<Column field="name" header="Name" style="width: 30%">
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<AppDataTable
:value="projects"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
v-model:selection="selectedProject"
selectionMode="single"
dataKey="id"
@row-select="onRowSelect"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[900px]"
>
<template #empty>
<div class="grid place-items-center gap-2 px-4 py-14 text-center">
<i class="pi pi-folder-open text-2xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">No projects found</p>
<p class="muted-note">Try a different search, or create your first project.</p>
</div>
</template>
<Column field="name" header="Name" style="width: 22%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<i class="pi pi-folder" style="color: #3b82f6"></i>
<span style="font-weight: 600">{{ data.name }}</span>
<i class="pi pi-folder text-fuchsia-500"></i>
<span class="font-medium">{{ data.name }}</span>
</div>
</template>
</Column>
<Column field="description" header="Description" style="width: 40%">
<Column field="description" header="Description" style="width: 22%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ data.description }}</span>
<span class="muted-note">{{ data.description || '—' }}</span>
</template>
</Column>
<Column field="status" header="Status" style="width: 15%">
<Column field="status" header="Status" style="width: 10%">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 15%">
<Column header="Created" style="width: 12%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
</template>
</Column>
</DataTable>
<Column header="Created by" style="width: 12%">
<template #body="{ data }">
<span class="muted-note">{{ data.createdByName || '—' }}</span>
</template>
</Column>
<Column header="Updated" style="width: 12%">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
<Column header="Updated by" style="width: 12%">
<template #body="{ data }">
<span class="muted-note">{{ data.updatedByName || '—' }}</span>
</template>
</Column>
</AppDataTable>
</div>
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
@@ -65,7 +104,9 @@
import { createProject, getProjects, searchProjects } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { Project } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const router = useRouter()
const toast = useToast()
@@ -76,6 +117,11 @@ const loading = ref(false)
const searchTerm = ref('')
const selectedProject = ref<Project | null>(null)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const createDialog = ref(false)
const newName = ref('')
const newDescription = ref('')
@@ -86,9 +132,14 @@ let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadProjects() {
loading.value = true
try {
projects.value = searchTerm.value
? await searchProjects(searchTerm.value)
: await getProjects()
if (searchTerm.value) {
projects.value = await searchProjects(searchTerm.value)
totalCount.value = projects.value.length
} else {
const res = await getProjects(page.value, pageSize.value)
projects.value = res.items
totalCount.value = res.totalCount
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -96,9 +147,18 @@ async function loadProjects() {
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void loadProjects()
}
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadProjects, 300)
searchTimer = setTimeout(() => {
page.value = 1
void loadProjects()
}, 300)
}
function onRowSelect() {
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="flex h-full flex-col">
<Tabs value="masterdata" class="flex min-h-0 flex-1 flex-col">
<TabList>
<Tab v-if="auth.canView('masterdata')" value="masterdata">Master Data</Tab>
<Tab v-if="auth.canView('permissions')" value="roles">Role</Tab>
<Tab v-if="auth.canView('permissions')" value="permissions">Permission</Tab>
</TabList>
<TabPanels class="min-h-0 flex-1">
<TabPanel value="masterdata" class="flex h-full min-h-0 flex-col">
<MasterDataView v-if="auth.canView('masterdata')" />
</TabPanel>
<TabPanel value="roles" class="flex h-full min-h-0 flex-col">
<RolesView v-if="auth.canView('permissions')" />
</TabPanel>
<TabPanel value="permissions" class="flex h-full min-h-0 flex-col">
<PermissionsView v-if="auth.canView('permissions')" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import MasterDataView from './masterdata/MasterDataView.vue'
import RolesView from './roles/RolesView.vue'
import PermissionsView from './permissions/PermissionsView.vue'
const auth = useAuthStore()
</script>
@@ -0,0 +1,197 @@
<template>
<div class="flex h-full flex-col">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="page-title m-0">Master Data</h1>
<Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
<AppDataTable
:value="entries"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
emptyMessage="No master data yet"
class="min-h-0 flex-1 min-w-[640px]"
>
<Column field="group" header="Group" style="width: 20%" />
<Column field="label" header="Label" style="width: 25%" />
<Column field="value" header="Value" style="width: 20%" />
<Column field="sortOrder" header="Sort" style="width: 10%" />
<Column header="Status" style="width: 15%">
<template #body="{ data }">
<ToggleSwitch
v-model="data.isActive"
:disabled="!auth.can('masterdata', 'edit')"
:aria-label="data.isActive ? 'Deactivate entry' : 'Activate entry'"
@change="onToggleActive(data)"
/>
</template>
</Column>
<Column header="" style="width: 10%">
<template #body="{ data }">
<div class="flex justify-end gap-1">
<Button v-if="auth.can('masterdata', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button
v-if="auth.can('masterdata', 'delete')"
icon="pi pi-trash"
text
severity="danger"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</AppDataTable>
</div>
</div>
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)">
<div class="field">
<label for="md-group">Group</label>
<InputText id="md-group" v-model.trim="form.group" class="w-full" autofocus />
</div>
<div class="field">
<label for="md-label">Label</label>
<InputText id="md-label" v-model.trim="form.label" class="w-full" />
</div>
<div class="field">
<label for="md-value">Value</label>
<InputText id="md-value" v-model.trim="form.value" class="w-full" />
</div>
<div class="field">
<label for="md-sort">Sort order</label>
<InputNumber id="md-sort" v-model="form.sortOrder" class="w-full" />
</div>
<div class="field flex items-center gap-2">
<ToggleSwitch v-model="form.isActive" inputId="md-active" />
<label for="md-active">Active</label>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getMasterDataList, createMasterData, updateMasterData, deleteMasterData } from '../../../services/backend'
import { errorMessage } from '../../../services/api'
import { useAuthStore } from '../../../stores/auth'
import AppDataTable from '../../../components/AppDataTable.vue'
import type { MasterDataItem, SaveMasterDataRequest } from '../../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const entries = ref<MasterDataItem[]>([])
const loading = ref(false)
const saving = ref(false)
const page = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const formDialog = ref(false)
const editTarget = ref<MasterDataItem | null>(null)
const form = ref<SaveMasterDataRequest>({ group: '', label: '', value: '', sortOrder: 0, isActive: true })
async function loadEntries() {
loading.value = true
try {
const res = await getMasterDataList(undefined, page.value, pageSize.value)
entries.value = res.items
totalCount.value = res.totalCount
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void loadEntries()
}
function openCreate() {
editTarget.value = null
form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true }
formDialog.value = true
}
function openEdit(entry: MasterDataItem) {
editTarget.value = entry
form.value = { group: entry.group, label: entry.label, value: entry.value, sortOrder: entry.sortOrder, isActive: entry.isActive }
formDialog.value = true
}
async function onToggleActive(entry: MasterDataItem) {
const prev = entry.isActive
try {
await updateMasterData(entry.id, {
group: entry.group,
label: entry.label,
value: entry.value,
sortOrder: entry.sortOrder,
isActive: entry.isActive,
})
toast.add({ severity: 'success', summary: entry.isActive ? 'Entry activated' : 'Entry deactivated', life: 2000 })
} catch (e) {
entry.isActive = prev
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
async function onSave() {
if (!form.value.group || !form.value.label || !form.value.value) {
toast.add({ severity: 'warn', summary: 'Group, label and value are required', life: 3000 })
return
}
saving.value = true
try {
if (editTarget.value) {
await updateMasterData(editTarget.value.id, form.value)
} else {
await createMasterData(form.value)
}
formDialog.value = false
toast.add({ severity: 'success', summary: 'Entry saved', life: 3000 })
await loadEntries()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmDelete(entry: MasterDataItem) {
confirm.require({
message: `Delete entry "${entry.label}"?`,
header: 'Delete',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteMasterData(entry.id)
toast.add({ severity: 'success', summary: 'Entry deleted', life: 2000 })
await loadEntries()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
onMounted(loadEntries)
</script>
@@ -0,0 +1,191 @@
<template>
<div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
<!-- Left: Role List -->
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
<div class="mb-3">
<IconField>
<InputIcon class="pi pi-search text-xs" />
<InputText
v-model.trim="roleSearchTerm"
placeholder="Search roles..."
class="search-input w-full text-sm"
/>
</IconField>
</div>
<div v-if="loadingRoles" class="flex flex-1 items-center justify-center">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<div v-else-if="filteredRoles.length === 0" class="muted-note p-4 text-center">
No roles found
</div>
<div v-else class="flex-1 overflow-y-auto pr-1 space-y-1">
<div
v-for="r in filteredRoles"
:key="r.id"
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors"
:class="selectedRoleId === r.id
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
@click="selectRole(r.id)"
>
<div class="flex items-center gap-2.5">
<span class="text-sm leading-tight">{{ r.name }}</span>
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
</div>
<i v-if="selectedRoleId === r.id" class="pi pi-chevron-right text-xs"></i>
</div>
</div>
</div>
<!-- Right: Role Screen Permissions Matrix -->
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;">
<div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note">
Select a role from the left list to view/edit permissions
</div>
<template v-else>
<div class="mb-3 flex items-center justify-between">
<div class="eyebrow">Screen Permissions ({{ selectedRole?.name }})</div>
<Button
label="Save Changes"
icon="pi pi-check"
size="small"
:loading="savingRolePermissions"
@click="handleSaveRolePermissions"
/>
</div>
<div v-if="loadingRoleDetail" class="flex flex-1 items-center justify-center">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<div v-else class="flex-1 overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b" style="border-color: var(--hairline)">
<th class="py-2.5 text-left font-medium">Screen</th>
<th class="w-20 text-center font-medium">View</th>
<th class="w-20 text-center font-medium">Create</th>
<th class="w-20 text-center font-medium">Edit</th>
<th class="w-20 text-center font-medium">Delete</th>
</tr>
</thead>
<tbody>
<tr v-for="item in rolePermissions" :key="item.screen" class="border-b" style="border-color: var(--hairline)">
<td class="py-3 font-medium">{{ screenLabel(item.screen) }}</td>
<td class="text-center">
<Checkbox v-model="item.canView" :binary="true" />
</td>
<td class="text-center">
<Checkbox v-model="item.canCreate" :binary="true" />
</td>
<td class="text-center">
<Checkbox v-model="item.canEdit" :binary="true" />
</td>
<td class="text-center">
<Checkbox v-model="item.canDelete" :binary="true" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getRoles, getRole, updateRole } from '../../../services/backend'
import { errorMessage } from '../../../services/api'
import type { Role, PermissionEntry } from '../../../types'
const toast = useToast()
const roles = ref<Role[]>([])
const loadingRoles = ref(false)
const roleSearchTerm = ref('')
const selectedRoleId = ref<string | null>(null)
const selectedRole = ref<Role | null>(null)
const rolePermissions = ref<PermissionEntry[]>([])
const loadingRoleDetail = ref(false)
const savingRolePermissions = ref(false)
const ALL_SCREENS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'projects', label: 'Projects' },
{ key: 'documents', label: 'Documents' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'users', label: 'Users' },
{ key: 'permissions', label: 'Permissions' },
{ key: 'masterdata', label: 'Master Data' },
]
const filteredRoles = computed(() => {
if (!roleSearchTerm.value) return roles.value
const term = roleSearchTerm.value.toLowerCase()
return roles.value.filter((r) => r.name.toLowerCase().includes(term))
})
async function fetchRoles() {
loadingRoles.value = true
try {
const res = await getRoles(1, 100)
roles.value = res.items
if (roles.value.length > 0 && !selectedRoleId.value) {
selectRole(roles.value[0].id)
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loadingRoles.value = false
}
}
async function selectRole(roleId: string) {
selectedRoleId.value = roleId
loadingRoleDetail.value = true
try {
const data = await getRole(roleId)
selectedRole.value = data
rolePermissions.value = ALL_SCREENS.map((s) => {
const existing = data.permissions.find((p) => p.screen === s.key)
return {
screen: s.key,
canView: existing?.canView ?? false,
canCreate: existing?.canCreate ?? false,
canEdit: existing?.canEdit ?? false,
canDelete: existing?.canDelete ?? false,
}
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loadingRoleDetail.value = false
}
}
function screenLabel(key: string): string {
return ALL_SCREENS.find((s) => s.key === key)?.label ?? key
}
async function handleSaveRolePermissions() {
if (!selectedRoleId.value || !selectedRole.value) return
savingRolePermissions.value = true
try {
await updateRole(selectedRoleId.value, {
name: selectedRole.value.name,
permissions: rolePermissions.value,
})
toast.add({ severity: 'success', summary: 'Permissions saved', life: 2000 })
await fetchRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
savingRolePermissions.value = false
}
}
onMounted(() => {
void fetchRoles()
})
</script>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
<!-- Left: User List -->
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
<div class="mb-3">
<IconField>
<InputIcon class="pi pi-search text-xs" />
<InputText
v-model.trim="userSearchTerm"
placeholder="Search users..."
class="search-input w-full text-sm"
@input="onUserSearch"
/>
</IconField>
</div>
<div v-if="loadingUsers" class="flex flex-1 items-center justify-center">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<div v-else-if="users.length === 0" class="muted-note p-4 text-center">
No users found
</div>
<div v-else class="flex-1 overflow-y-auto pr-1">
<div
v-for="u in users"
:key="u.id"
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors mb-1"
:class="selectedUserId === u.id
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
@click="selectUser(u.id)"
>
<div class="flex items-center gap-2.5">
<Avatar
:label="(u.displayName || u.username).slice(0, 2).toUpperCase()"
style="background: var(--primary); color: #fff"
shape="circle"
size="normal"
/>
<div>
<div class="text-sm leading-tight">{{ u.displayName }}</div>
<div class="muted-note text-xs">@{{ u.username }}</div>
</div>
</div>
<i v-if="selectedUserId === u.id" class="pi pi-chevron-right text-xs"></i>
</div>
</div>
</div>
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
<div class="flex flex-col gap-4 md:col-span-8">
<div v-if="!selectedUserId" class="panel flex items-center justify-center p-8 muted-note" style="height: 620px;">
Select a user from the left list to manage roles
</div>
<template v-else>
<!-- Top Right: Unassigned Roles -->
<div class="panel flex flex-col p-4" style="height: 300px;">
<div class="eyebrow mb-3 flex items-center gap-2">
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
</div>
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
All available roles assigned
</div>
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
<div
v-for="r in unassignedRoles"
:key="r.id"
class="flex items-center justify-between rounded-xl border p-3"
style="border-color: var(--hairline)"
>
<div class="flex items-center gap-3">
<span class="font-medium text-sm">{{ r.name }}</span>
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
</div>
<Button
label="Assign"
icon="pi pi-plus"
size="small"
:loading="assigningRoleId === r.id"
@click="handleAssignRole(r.id)"
/>
</div>
</div>
</div>
<!-- Bottom Right: Assigned Roles -->
<div class="panel flex flex-col p-4" style="height: 304px;">
<div class="eyebrow mb-3 flex items-center gap-2">
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
</div>
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
No roles assigned yet
</div>
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
<div
v-for="r in assignedRoles"
:key="r.id"
class="flex items-center justify-between rounded-xl border p-3"
style="border-color: var(--hairline)"
>
<div class="flex items-center gap-3">
<span class="font-medium text-sm">{{ r.name }}</span>
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
</div>
<Button
label="Unassign"
icon="pi pi-times"
severity="danger"
text
size="small"
:loading="unassigningRoleId === r.id"
@click="handleUnassignRole(r.id)"
/>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getUsers, getUserRoles, assignUserRole, unassignUserRole } from '../../../services/backend'
import { errorMessage } from '../../../services/api'
import type { User, Role } from '../../../types'
const toast = useToast()
const users = ref<User[]>([])
const loadingUsers = ref(false)
const userSearchTerm = ref('')
const selectedUserId = ref<string | null>(null)
const assignedRoles = ref<Role[]>([])
const unassignedRoles = ref<Role[]>([])
const loadingUserRoles = ref(false)
const assigningRoleId = ref<string | null>(null)
const unassigningRoleId = ref<string | null>(null)
let userSearchTimer: ReturnType<typeof setTimeout> | undefined
async function fetchUsers(q?: string) {
loadingUsers.value = true
try {
users.value = await getUsers(q)
if (users.value.length > 0 && !selectedUserId.value) {
selectUser(users.value[0].id)
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loadingUsers.value = false
}
}
function onUserSearch() {
clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(() => void fetchUsers(userSearchTerm.value), 300)
}
async function selectUser(userId: string) {
selectedUserId.value = userId
loadingUserRoles.value = true
try {
const data = await getUserRoles(userId)
assignedRoles.value = data.assignedRoles
unassignedRoles.value = data.unassignedRoles
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loadingUserRoles.value = false
}
}
async function handleAssignRole(roleId: string) {
if (!selectedUserId.value) return
assigningRoleId.value = roleId
try {
await assignUserRole(selectedUserId.value, roleId)
toast.add({ severity: 'success', summary: 'Role assigned', life: 2000 })
await selectUser(selectedUserId.value)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
assigningRoleId.value = null
}
}
async function handleUnassignRole(roleId: string) {
if (!selectedUserId.value) return
unassigningRoleId.value = roleId
try {
await unassignUserRole(selectedUserId.value, roleId)
toast.add({ severity: 'success', summary: 'Role unassigned', life: 2000 })
await selectUser(selectedUserId.value)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
unassigningRoleId.value = null
}
}
onMounted(() => {
void fetchUsers()
})
</script>
+11 -11
View File
@@ -66,6 +66,7 @@
<script setup lang="ts">
import { createTask, updateTask, deleteTask } from '../../services/modules'
import { getMasterDataOptions } from '../../services/backend'
import { errorMessage } from '../../services/api'
import type { Task, TaskPriority, TaskStatus } from '../../types'
@@ -88,18 +89,17 @@ const toast = useToast()
const saving = ref(false)
const savingError = ref('')
const statusOptions = [
{ label: 'To Do', value: 'Todo' as TaskStatus },
{ label: 'In Progress', value: 'InProgress' as TaskStatus },
{ label: 'Done', value: 'Done' as TaskStatus },
{ label: 'Cancelled', value: 'Cancelled' as TaskStatus },
]
const statusOptions = ref<{ label: string; value: TaskStatus }[]>([])
const priorityOptions = ref<{ label: string; value: TaskPriority }[]>([])
const priorityOptions = [
{ label: 'Low', value: 'Low' as TaskPriority },
{ label: 'Medium', value: 'Medium' as TaskPriority },
{ label: 'High', value: 'High' as TaskPriority },
]
onMounted(async () => {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = status as { label: string; value: TaskStatus }[]
priorityOptions.value = priority as { label: string; value: TaskPriority }[]
})
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
+16 -7
View File
@@ -18,7 +18,7 @@
<div
v-for="col in columns"
:key="col.status"
class="min-w-[260px] flex-1 rounded-lg bg-slate-100 p-2 dark:bg-slate-800"
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
@dragover.prevent="dragOverStatus = col.status"
@dragleave="dragOverStatus = null"
@drop.prevent="onDrop(col.status)"
@@ -30,7 +30,8 @@
<div
v-for="task in tasksIn(col.status)"
:key="task.id"
class="mb-2 cursor-pointer rounded-lg border border-slate-200 bg-white p-3 hover:border-blue-500 dark:border-slate-700 dark:bg-slate-900"
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
:class="{ 'opacity-40': draggingId === task.id }"
:draggable="auth.can('tasks', 'edit')"
@dragstart="onDragStart(task)"
@@ -38,11 +39,11 @@
@click="openEdit(task)"
>
<div style="font-weight: 500">{{ task.title }}</div>
<div v-if="task.description" class="mt-1 truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ task.description }}</div>
<div v-if="task.description" class="muted-note mt-1 truncate">{{ task.description }}</div>
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
<span class="text-slate-500 dark:text-slate-400">{{ task.assigneeName ?? 'Unassigned' }}</span>
<span v-if="task.dueDate" class="text-slate-500 dark:text-slate-400">{{ formatDate(task.dueDate) }}</span>
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
</div>
</div>
</div>
@@ -96,7 +97,8 @@ function tasksIn(status: TaskStatus) {
async function load() {
try {
tasks.value = await getTasks(projectId)
const res = await getTasks(projectId, undefined, 1, 100)
tasks.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
@@ -104,7 +106,8 @@ async function load() {
async function loadMembers() {
try {
members.value = (await getMembers(projectId)).map((m) => ({
const res = await getMembers(projectId, 1, 100)
members.value = res.items.map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
@@ -182,3 +185,9 @@ onMounted(async () => {
await load()
})
</script>
<style scoped>
.task-card:hover {
border-color: var(--primary) !important;
}
</style>
+58 -34
View File
@@ -1,5 +1,5 @@
<template>
<div>
<div class="flex h-full flex-col">
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2">
<Select
@@ -39,13 +39,28 @@
</div>
</div>
<div class="overflow-x-auto">
<DataTable :value="filteredTasks" :loading="loading" dataKey="id" emptyMessage="No tasks"
@row-click="openEdit" class="min-w-[700px]">
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
<AppDataTable
:value="tasks"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
dataKey="id"
emptyMessage="No tasks yet"
@row-click="openEdit"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[700px]"
>
<Column field="title" header="Title" style="width: 35%">
<template #body="{ data }">
<div style="font-weight: 500">{{ data.title }}</div>
<div v-if="data.description" class="max-w-[400px] truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ data.description }}</div>
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
</template>
</Column>
<Column field="status" header="Status" style="width: 12%">
@@ -70,10 +85,11 @@
</Column>
<Column header="Updated" style="width: 12%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</AppDataTable>
</div>
</div>
<TaskDetailDialog
@@ -92,10 +108,12 @@
<script setup lang="ts">
import TaskDetailDialog from './TaskDetailDialog.vue'
import { getTasks } from '../../services/modules'
import { getMembers } from '../../services/backend'
import { getMembers, getMasterDataOptions } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { Task, TaskPriority, TaskStatus } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const route = useRoute()
const toast = useToast()
@@ -106,6 +124,11 @@ const tasks = ref<Task[]>([])
const loading = ref(false)
const members = ref<{ userId: string; displayName: string }[]>([])
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const filterStatus = ref<string | null>(null)
const filterPriority = ref<string | null>(null)
const filterAssignee = ref<string | null>(null)
@@ -115,36 +138,24 @@ const editingTask = ref<Task | null>(null)
const boardMode = computed(() => route.name === 'tasks-board')
const statusOptions = [
{ label: 'Todo', value: 'Todo' },
{ label: 'In Progress', value: 'InProgress' },
{ label: 'Done', value: 'Done' },
{ label: 'Cancelled', value: 'Cancelled' },
]
const priorityOptions = [
{ label: 'Low', value: 'Low' },
{ label: 'Medium', value: 'Medium' },
{ label: 'High', value: 'High' },
]
const statusOptions = ref<{ label: string; value: string }[]>([])
const priorityOptions = ref<{ label: string; value: string }[]>([])
const assigneeOptions = computed(() =>
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
)
const filteredTasks = computed(() => {
return tasks.value.filter((t) => {
if (filterStatus.value && t.status !== filterStatus.value) return false
if (filterPriority.value && t.priority !== filterPriority.value) return false
if (filterAssignee.value && t.assigneeId !== filterAssignee.value) return false
return true
})
})
async function load() {
loading.value = true
try {
tasks.value = await getTasks(projectId)
const filters = {
status: filterStatus.value || undefined,
priority: filterPriority.value || undefined,
assigneeId: filterAssignee.value || undefined,
}
const res = await getTasks(projectId, filters, page.value, pageSize.value)
tasks.value = res.items
totalCount.value = res.totalCount
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -152,9 +163,16 @@ async function load() {
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void load()
}
async function loadMembers() {
try {
members.value = (await getMembers(projectId)).map((m) => ({
const res = await getMembers(projectId, 1, 100)
members.value = res.items.map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
@@ -163,9 +181,9 @@ async function loadMembers() {
}
}
watch(filterStatus, load)
watch(filterPriority, load)
watch(filterAssignee, load)
watch(filterStatus, () => { page.value = 1; void load() })
watch(filterPriority, () => { page.value = 1; void load() })
watch(filterAssignee, () => { page.value = 1; void load() })
function openCreate() {
editingTask.value = null
@@ -208,6 +226,12 @@ function formatDate(v: string) {
}
onMounted(async () => {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = status
priorityOptions.value = priority
await loadMembers()
await load()
})
@@ -1,48 +1,65 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Accounts</h1>
<Button v-if="auth.can('accounts', 'create')" label="New Account" icon="pi pi-plus" @click="openCreate" />
<div class="flex h-full flex-col">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="page-title m-0">Users</h1>
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="mb-3">
<InputText v-model.trim="searchTerm" placeholder="Search accounts..." class="w-full sm:w-[320px]" @input="debouncedSearch" />
<div class="mb-4 sm:w-[320px]">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
</IconField>
</div>
<div class="overflow-x-auto">
<DataTable :value="accounts" :loading="loading" emptyMessage="No accounts" class="min-w-[640px]">
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
<AppDataTable
:value="users"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
emptyMessage="No users yet"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[640px]"
>
<Column header="User" style="width: 35%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
style="background: #3b82f6; color: #fff" />
style="background: var(--primary); color: #fff" />
<span>{{ data.displayName }}</span>
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
<span class="muted-note">@{{ data.username }}</span>
</div>
</template>
</Column>
<Column field="roleName" header="Role" style="width: 15%">
<Column field="isActive" header="Status" style="width: 25%">
<template #body="{ data }">
<Tag :value="data.roleName" severity="secondary" />
<ToggleSwitch
v-model="data.isActive"
:disabled="!auth.can('users', 'edit')"
:aria-label="data.isActive ? 'Deactivate user' : 'Activate user'"
@change="onToggleActive(data)"
/>
</template>
</Column>
<Column field="isActive" header="Status" style="width: 15%">
<Column header="Created" style="width: 20%">
<template #body="{ data }">
<Tag :value="data.isActive ? 'Active' : 'Disabled'" :severity="data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column header="Created" style="width: 15%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.createdAt) }}</span>
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
</template>
</Column>
<Column header="" style="width: 20%">
<template #body="{ data }">
<div class="flex justify-end gap-1">
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button v-if="auth.can('users', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
<Button v-if="auth.can('users', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button
v-if="auth.can('accounts', 'delete') && data.id !== auth.user?.id"
v-if="auth.can('users', 'delete') && data.id !== auth.user?.id"
icon="pi pi-trash"
text
severity="danger"
@@ -51,10 +68,11 @@
</div>
</template>
</Column>
</DataTable>
</AppDataTable>
</div>
</div>
<Dialog v-model:visible="createDialog" header="New Account" :modal="true" style="width: min(460px, 92vw)">
<Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)">
<div class="field">
<label for="acc-username">Username</label>
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
@@ -67,25 +85,17 @@
<label for="acc-pass">Password</label>
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
</div>
<div class="field">
<label for="acc-role">Role</label>
<Select id="acc-role" v-model="createForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" :loading="saving" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="editDialog" header="Edit Account" :modal="true" style="width: min(460px, 92vw)">
<Dialog v-model:visible="editDialog" header="Edit User" :modal="true" style="width: min(460px, 92vw)">
<div class="field">
<label for="edit-name">Display name</label>
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
</div>
<div class="field">
<label for="edit-role">Role</label>
<Select id="edit-role" v-model="editForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="field flex items-center gap-2">
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
<label for="edit-active">Active</label>
@@ -110,39 +120,46 @@
</template>
<script setup lang="ts">
import { getAccounts, createAccount, updateAccount, deleteAccount, resetAccountPassword, getRoles } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { Account } from '../types'
import { getUsersPaged, createUser, updateUser, deleteUser, resetUserPassword } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { UserListItem } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const accounts = ref<Account[]>([])
const users = ref<UserListItem[]>([])
const loading = ref(false)
const saving = ref(false)
const searchTerm = ref('')
const roleOptions = ref<{ label: string; value: string }[]>([])
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const createDialog = ref(false)
const createForm = ref({ username: '', displayName: '', password: '', roleId: '' })
const createForm = ref({ username: '', displayName: '', password: '' })
const editDialog = ref(false)
const editTarget = ref<Account | null>(null)
const editForm = ref({ displayName: '', roleId: '', isActive: true })
const editTarget = ref<UserListItem | null>(null)
const editForm = ref({ displayName: '', isActive: true })
const resetDialog = ref(false)
const resetTarget = ref<Account | null>(null)
const resetTarget = ref<UserListItem | null>(null)
const resetPassword = ref('')
let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadAccounts() {
async function loadUsers() {
loading.value = true
try {
accounts.value = await getAccounts(searchTerm.value || undefined)
const res = await getUsersPaged(searchTerm.value || undefined, page.value, pageSize.value)
users.value = res.items
totalCount.value = res.totalCount
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -150,21 +167,22 @@ async function loadAccounts() {
}
}
async function loadRoles() {
try {
roleOptions.value = (await getRoles()).map((r) => ({ label: r.name, value: r.id }))
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void loadUsers()
}
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadAccounts, 300)
searchTimer = setTimeout(() => {
page.value = 1
void loadUsers()
}, 300)
}
function openCreate() {
createForm.value = { username: '', displayName: '', password: '', roleId: roleOptions.value[0]?.value ?? '' }
createForm.value = { username: '', displayName: '', password: '' }
createDialog.value = true
}
@@ -175,10 +193,10 @@ async function onCreate() {
}
saving.value = true
try {
await createAccount(createForm.value)
await createUser(createForm.value)
createDialog.value = false
toast.add({ severity: 'success', summary: 'Account created', life: 3000 })
await loadAccounts()
toast.add({ severity: 'success', summary: 'User created', life: 3000 })
await loadUsers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -186,9 +204,9 @@ async function onCreate() {
}
}
function openEdit(account: Account) {
editTarget.value = account
editForm.value = { displayName: account.displayName, roleId: account.roleId, isActive: account.isActive }
function openEdit(user: UserListItem) {
editTarget.value = user
editForm.value = { displayName: user.displayName, isActive: user.isActive }
editDialog.value = true
}
@@ -196,10 +214,10 @@ async function onEdit() {
if (!editTarget.value) return
saving.value = true
try {
await updateAccount(editTarget.value.id, editForm.value)
await updateUser(editTarget.value.id, editForm.value)
editDialog.value = false
toast.add({ severity: 'success', summary: 'Account updated', life: 3000 })
await loadAccounts()
toast.add({ severity: 'success', summary: 'User updated', life: 3000 })
await loadUsers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -207,8 +225,8 @@ async function onEdit() {
}
}
function openReset(account: Account) {
resetTarget.value = account
function openReset(user: UserListItem) {
resetTarget.value = user
resetPassword.value = ''
resetDialog.value = true
}
@@ -220,7 +238,7 @@ async function onReset() {
}
saving.value = true
try {
await resetAccountPassword(resetTarget.value.id, resetPassword.value)
await resetUserPassword(resetTarget.value.id, resetPassword.value)
resetDialog.value = false
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
} catch (e) {
@@ -230,15 +248,17 @@ async function onReset() {
}
}
function confirmDelete(account: Account) {
function confirmDelete(user: UserListItem) {
confirm.require({
message: `Delete account "${account.displayName}"?`,
message: `Delete user "${user.displayName}"?`,
header: 'Delete',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteAccount(account.id)
toast.add({ severity: 'success', summary: 'Account deleted', life: 2000 })
await loadAccounts()
await deleteUser(user.id)
toast.add({ severity: 'success', summary: 'User deleted', life: 2000 })
await loadUsers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
@@ -250,7 +270,18 @@ function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
async function onToggleActive(user: UserListItem) {
const prev = user.isActive
try {
await updateUser(user.id, { displayName: user.displayName, isActive: user.isActive })
toast.add({ severity: 'success', summary: user.isActive ? 'User activated' : 'User disabled', life: 2000 })
} catch (e) {
user.isActive = prev
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
onMounted(async () => {
await Promise.all([loadAccounts(), loadRoles()])
await loadUsers()
})
</script>