diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml
index 011f636a8..4bfd95642 100644
--- a/.github/workflows/lighthouse.yml
+++ b/.github/workflows/lighthouse.yml
@@ -19,7 +19,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '16.x'
+ node-version: '22.x'
- name: install node deps
run: npm install
- name: node build
@@ -38,6 +38,10 @@ jobs:
install: localdb
- name: migrate
run: .\.dotnet-tools\dotnet-ef database update --project web/web.csproj
+ env:
+ Jwt__Key: "lighthouse-ci-test-jwt-key-minimum-32-characters-required"
+ Jwt__Issuer: "atlas-lighthouse-ci"
+ Jwt__Audience: "atlas-lighthouse-ci"
- name: run Lighthouse CI
run: |
npm install -g @lhci/cli@0.9.x
@@ -45,3 +49,8 @@ jobs:
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
+ Jwt__Key: "lighthouse-ci-test-jwt-key-minimum-32-characters-required"
+ Jwt__Issuer: "atlas-lighthouse-ci"
+ Jwt__Audience: "atlas-lighthouse-ci"
+ Cors__AllowedOrigins__0: "http://localhost:3000"
+ Auth__DefaultCallbackPath: "/auth/callback"
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7676f67d0..9aba64a74 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -19,7 +19,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '20.x'
+ node-version: '22.x'
- name: install node deps
run: npm install --ignore-scripts --no-audit --no-fund
- name: Semantic Release
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index f1e11f581..6b8136283 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -38,6 +38,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+ Jwt__Key: "sonar-ci-test-jwt-key-minimum-32-characters-required-for-build"
+ Jwt__Issuer: "atlas-sonar-ci"
+ Jwt__Audience: "atlas-sonar-ci"
+ Cors__AllowedOrigins__0: "http://localhost:3000"
+ Auth__DefaultCallbackPath: "/auth/callback"
shell: powershell
run: |
.\.sonar\scanner\dotnet-sonarscanner begin /k:"atlas-bi_atlas-bi-library" /o:"atlas-bi" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io"
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index cba802ace..a1c4f66b0 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -17,7 +17,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '18.x'
+ node-version: '22.x'
- name: install node deps
run: npm install --ignore-scripts --no-audit --no-fund
@@ -41,7 +41,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '18.x'
+ node-version: '22.x'
- name: install node deps
run: npm install --ignore-scripts --no-audit --no-fund
- name: node build
@@ -82,7 +82,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '18.x'
+ node-version: '22.x'
- name: setup java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
with:
@@ -94,7 +94,6 @@ jobs:
run: npm run build
- name: install dotnet deps
run: |
- dotnet tool install -g coverlet.console
dotnet tool install -g dotnet-reportgenerator-globaltool
dotnet restore
- name: build
@@ -109,6 +108,21 @@ jobs:
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
+ - name: collect coverage file
+ if: always()
+ shell: pwsh
+ run: |
+ $coverageFile = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml |
+ Sort-Object LastWriteTimeUtc -Descending |
+ Select-Object -First 1
+
+ if ($coverageFile) {
+ Copy-Item $coverageFile.FullName coverage.cobertura.xml -Force
+ Write-Host "Copied coverage file from $($coverageFile.FullName)"
+ } else {
+ Write-Host "Coverage file not found under TestResults"
+ }
+
- name: console coverage
if: always()
run: |
@@ -126,6 +140,8 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de
with:
files: ./coverage.cobertura.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: false
verbose: true
# browser_tests:
@@ -204,7 +220,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
- node-version: '18.x'
+ node-version: '22.x'
- name: setup java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
with:
@@ -216,7 +232,6 @@ jobs:
run: npm run build
- name: install dotnet deps
run: |
- dotnet tool install -g coverlet.console
dotnet tool install -g dotnet-reportgenerator-globaltool
dotnet restore
- name: build
@@ -231,6 +246,21 @@ jobs:
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
+ - name: collect coverage file
+ if: always()
+ shell: pwsh
+ run: |
+ $coverageFile = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml |
+ Sort-Object LastWriteTimeUtc -Descending |
+ Select-Object -First 1
+
+ if ($coverageFile) {
+ Copy-Item $coverageFile.FullName coverage.cobertura.xml -Force
+ Write-Host "Copied coverage file from $($coverageFile.FullName)"
+ } else {
+ Write-Host "Coverage file not found under TestResults"
+ }
+
- name: console coverage
if: always()
run: |
@@ -248,4 +278,6 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de
with:
files: ./coverage.cobertura.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: false
verbose: true
diff --git a/docker-compose.yml b/docker-compose.yml
index 07ab466fa..31130c17b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,6 @@
-version: '3.9'
-
services:
sqlserver:
- image: mcr.microsoft.com/mssql/server:2022-latest
+ image: mcr.microsoft.com/mssql/server:2025-latest
container_name: atlas_sqlserver
environment:
ACCEPT_EULA: 'Y'
@@ -10,6 +8,8 @@ services:
MSSQL_PID: ${MSSQL_PID:-Evaluation}
expose:
- 1433
+ ulimits:
+ core: 0
volumes:
- sqlserver_data:/var/opt/mssql
healthcheck:
@@ -45,7 +45,7 @@ services:
test:
[
'CMD-SHELL',
- 'wget -qO- http://localhost:${SOLR_PORT:-8983}/solr/admin/ping | grep -q ''"status":"OK"''',
+ 'wget -qO- http://127.0.0.1:${SOLR_PORT:-8983}/solr/atlas/admin/ping | grep -q ''"status":"OK"''',
]
interval: 15s
timeout: 5s
@@ -66,19 +66,44 @@ services:
- solr
- sqlserver
environment:
- PORT: ${WEB_PORT:-3000}
+ PORT: ${WEB_PORT:-5000}
+ Demo: ${DEMO:-true}
SEED_DEMO: ${SEED_DEMO:-true}
+ DEMO_ADMIN_USERNAME: ${DEMO_ADMIN_USERNAME:-local-admin}
+ Jwt__Key: ${JWT_KEY}
+ Jwt__Issuer: ${JWT_ISSUER}
+ Jwt__Audience: ${JWT_AUDIENCE}
expose:
- - ${WEB_PORT:-3000}
+ - ${WEB_PORT:-5000}
ports:
- - '${WEB_PORT:-3000}:${WEB_PORT:-3000}'
+ - '${WEB_PORT:-5000}:${WEB_PORT:-5000}'
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ args:
+ API_URL: http://web:${WEB_PORT:-5000}
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5000}
+ container_name: atlas_frontend
+ depends_on:
+ - web
+ environment:
+ API_URL: http://web:${WEB_PORT:-5000}
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5000}
+ AUTH_RETURN_URL_ORIGIN: ${AUTH_RETURN_URL_ORIGIN:-http://localhost:3000}
+ DEMO: ${DEMO:-true}
+ DEMO_ADMIN_USERNAME: ${DEMO_ADMIN_USERNAME:-local-admin}
+ expose:
+ - 3000
+ ports:
+ - '${FRONTEND_PORT:-3000}:3000'
healthcheck:
- test:
- ['CMD-SHELL', 'wget -qO- http://localhost:${WEB_PORT:-3000}/ || exit 1']
+ test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:3000/ || exit 1']
interval: 15s
timeout: 5s
- retries: 10
- start_period: 20s
+ retries: 5
+ start_period: 15s
volumes:
sqlserver_data:
diff --git a/frontend/.czrc b/frontend/.czrc
new file mode 100644
index 000000000..025583e59
--- /dev/null
+++ b/frontend/.czrc
@@ -0,0 +1,4 @@
+{
+ "path": "cz-conventional-changelog"
+}
+
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 000000000..7b7ce6a2a
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,5 @@
+.next
+node_modules
+.git
+.env*
+!.env.example
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 000000000..5ef6a5207
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/frontend/.husky/_/husky.sh b/frontend/.husky/_/husky.sh
new file mode 100644
index 000000000..98a0182ab
--- /dev/null
+++ b/frontend/.husky/_/husky.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env sh
+# Minimal husky shim
+
+if [ -z "$husky_skip_init" ]; then
+ debug () {
+ if [ "${HUSKY_DEBUG}" = "1" ]; then
+ echo "husky (debug) - $1"
+ fi
+ }
+
+ readonly hook_name="$(basename "$0")"
+ debug "starting $hook_name..."
+
+ if [ "$HUSKY" = "0" ]; then
+ debug "HUSKY=0, skipping hook"
+ exit 0
+ fi
+
+ if [ -f ~/.huskyrc ]; then
+ debug "sourcing ~/.huskyrc"
+ . ~/.huskyrc
+ fi
+fi
diff --git a/frontend/.husky/commit-msg b/frontend/.husky/commit-msg
new file mode 100755
index 000000000..063f4b8cd
--- /dev/null
+++ b/frontend/.husky/commit-msg
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+pnpm -s exec commitlint --edit "$1"
+
diff --git a/frontend/.husky/install.mjs b/frontend/.husky/install.mjs
new file mode 100644
index 000000000..80cab903c
--- /dev/null
+++ b/frontend/.husky/install.mjs
@@ -0,0 +1,53 @@
+import fs from "node:fs"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const root = path.resolve(__dirname, "..")
+const huskyDir = path.join(root, ".husky")
+
+// Avoid running in CI environments (mirrors common Husky patterns)
+if (process.env.CI) {
+ process.exit(0)
+}
+
+const hooksDir = path.join(root, ".git", "hooks")
+if (!fs.existsSync(hooksDir)) {
+ // Not a git checkout (or .git missing) – silently no-op.
+ process.exit(0)
+}
+
+const huskySh = `#!/usr/bin/env sh
+# Minimal husky shim
+
+if [ -z "$husky_skip_init" ]; then
+ debug () {
+ if [ "\${HUSKY_DEBUG}" = "1" ]; then
+ echo "husky (debug) - $1"
+ fi
+ }
+
+ readonly hook_name="$(basename "$0")"
+ debug "starting $hook_name..."
+
+ if [ "$HUSKY" = "0" ]; then
+ debug "HUSKY=0, skipping hook"
+ exit 0
+ fi
+
+ if [ -f ~/.huskyrc ]; then
+ debug "sourcing ~/.huskyrc"
+ . ~/.huskyrc
+ fi
+fi
+`
+
+const ensureDir = (p) => {
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
+}
+
+ensureDir(path.join(huskyDir, "_"))
+
+// Minimal husky shim so the hook scripts can run consistently.
+fs.writeFileSync(path.join(huskyDir, "_", "husky.sh"), huskySh, "utf8")
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
new file mode 100755
index 000000000..e0b5a3b91
--- /dev/null
+++ b/frontend/.husky/pre-commit
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+pnpm lint-staged
+
diff --git a/frontend/.husky/prepare-commit-msg b/frontend/.husky/prepare-commit-msg
new file mode 100755
index 000000000..f36f30d15
--- /dev/null
+++ b/frontend/.husky/prepare-commit-msg
@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+# Commitizen interactive prompt (like the other project)
+exec < /dev/tty && pnpm -s exec cz --hook || exit 0
+
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 000000000..c4736ae6a
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,31 @@
+FROM node:22-alpine AS base
+RUN npm install -g pnpm@10.32.1
+
+FROM base AS deps
+WORKDIR /app
+COPY package.json pnpm-lock.yaml ./
+COPY .husky/install.mjs ./.husky/install.mjs
+RUN pnpm install --frozen-lockfile
+
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ARG API_URL
+ARG NEXT_PUBLIC_API_URL
+ENV API_URL=$API_URL
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+RUN pnpm build
+
+FROM base AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+USER nextjs
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+CMD ["node", "server.js"]
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 000000000..7e490454a
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,95 @@
+
+
+
+
+
+/
+library
+
+https://avatars.githubusercontent.com/u/90376906?s=400&u=7592339d75e23b1e5b5db486c48271f6491d1422&v=4
+Atlas BI Library (Next.js Frontend) | The unified report library.
+
+
+ Website • Demo • Documentation • Chat
+
+
+
+Atlas business intelligence library plugs in to your existing reporting platforms, extracts useful metadata, and displays it in a unified report library where you can easily search for, document, and launch reports.
+
+
+
+
+
+
+
+
+
+
+
+
+## 🏃 Getting Started
+
+> Atlas BI Library is built with [Next.js](https://nextjs.org) and [Node](https://nodejs.org/en/download/). \
+> It serves as the frontend for the Atlas BI Library C# API.
+
+- Install the project dependencies `npm install`
+- Connect to the C# Backend API by setting environment variables in `.env.local`
+- Start up the website `npm run dev`
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+## 🚀 Deploy to Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+
+## 🎁 Contributing
+
+Contributions are welcome! Please open an [issue](https://github.com/atlas-bi/atlas-bi-library/issues) describing an issue or feature.
+
+This repository uses commitizen. Commit code changes for pr's with `npm run commit`.
+
+## 🏆 Credits
+
+Atlas was originally created and made open source by the Riverside Healthcare Analytics team. See the [credits](https://www.atlas.bi/about/) for more details.
+
+## 🔧 Tools
+
+Special thanks to a few other tools used here.
+
+
+
+ />
+
+
+ [](https://sonarcloud.io/summary/new_code?id=atlas-bi_atlas-bi-library)
+[](https://sonarcloud.io/summary/new_code?id=atlas-bi_atlas-bi-library)
+[](https://sonarcloud.io/summary/new_code?id=atlas-bi_atlas-bi-library)
\ No newline at end of file
diff --git a/frontend/app/api/home/[tab]/route.ts b/frontend/app/api/home/[tab]/route.ts
new file mode 100644
index 000000000..df55b2a6c
--- /dev/null
+++ b/frontend/app/api/home/[tab]/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server"
+import { getCurrentUser } from "@/lib/auth"
+import { getHomeTabPanel, getHomeUserPageSummary } from "@/lib/home/api"
+import { isHomeTabId } from "@/lib/home/constants"
+
+export async function GET(_request: Request, context: { params: Promise<{ tab: string }> }) {
+ const user = await getCurrentUser()
+ if (!user) {
+ return NextResponse.json({ ok: false, error: "auth_required" }, { status: 401 })
+ }
+
+ const { tab } = await context.params
+ if (!isHomeTabId(tab)) {
+ return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 })
+ }
+
+ const userId = Number(user.userId)
+ if (!Number.isFinite(userId) || userId <= 0) {
+ return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 })
+ }
+
+ const page = await getHomeUserPageSummary(userId)
+ if (!page.data) {
+ return NextResponse.json({ ok: false, error: page.error ?? "unknown" }, { status: 400 })
+ }
+
+ const panel = await getHomeTabPanel(userId, page.data.defaultReportTypeIds, tab)
+ if (!panel.data) {
+ return NextResponse.json({ ok: false, error: panel.error ?? "unknown" }, { status: 400 })
+ }
+
+ return NextResponse.json({ ok: true, data: panel.data })
+}
diff --git a/frontend/app/auth/admin-mode/route.ts b/frontend/app/auth/admin-mode/route.ts
new file mode 100644
index 000000000..175ba2a46
--- /dev/null
+++ b/frontend/app/auth/admin-mode/route.ts
@@ -0,0 +1,42 @@
+import { cookies } from "next/headers"
+import { type NextRequest, NextResponse } from "next/server"
+import { getServerApiBase } from "@/lib/api-base"
+
+export async function GET(request: NextRequest) {
+ const token = (await cookies()).get("atlas_token")?.value
+ const returnTo = request.nextUrl.searchParams.get("returnTo") || "/"
+ const publicOrigin = process.env.AUTH_RETURN_URL_ORIGIN ?? request.nextUrl.origin
+ const parsedReturnTo = new URL(returnTo, publicOrigin)
+ const redirectUrl = new URL(
+ `${parsedReturnTo.pathname}${parsedReturnTo.search}${parsedReturnTo.hash}` || "/",
+ publicOrigin,
+ )
+ redirectUrl.searchParams.set("_admin", String(Date.now()))
+
+ if (!token) {
+ return NextResponse.redirect(redirectUrl)
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return NextResponse.redirect(redirectUrl)
+ }
+
+ try {
+ const result = await fetch(`${apiBase}/api/users/me/admin-mode/toggle`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ })
+ if (!result.ok) {
+ redirectUrl.searchParams.set("adminToggle", "failed")
+ }
+ } catch {
+ redirectUrl.searchParams.set("adminToggle", "failed")
+ return NextResponse.redirect(redirectUrl)
+ }
+
+ return NextResponse.redirect(redirectUrl)
+}
diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts
new file mode 100644
index 000000000..87f31bb57
--- /dev/null
+++ b/frontend/app/auth/callback/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server"
+import { getAuthRedirectOrigin } from "../redirect-origin"
+
+const tokenCookieName = "atlas_token"
+const maxAgeSeconds = 8 * 60 * 60
+
+export function GET(request: Request) {
+ const url = new URL(request.url)
+ const redirectOrigin = getAuthRedirectOrigin(request.url)
+ const token = url.searchParams.get("token")
+
+ if (!token) {
+ return NextResponse.redirect(new URL("/auth/login", redirectOrigin))
+ }
+
+ const response = NextResponse.redirect(new URL("/", redirectOrigin))
+ response.cookies.set({
+ name: tokenCookieName,
+ value: token,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: url.protocol === "https:",
+ path: "/",
+ maxAge: maxAgeSeconds,
+ })
+
+ return response
+}
diff --git a/frontend/app/auth/login/route.ts b/frontend/app/auth/login/route.ts
new file mode 100644
index 000000000..b48e0072e
--- /dev/null
+++ b/frontend/app/auth/login/route.ts
@@ -0,0 +1,10 @@
+import { redirect } from "next/navigation"
+import type { NextRequest } from "next/server"
+import { getPublicApiBase } from "@/lib/api-base"
+
+export function GET(request: NextRequest) {
+ const authReturnOrigin = process.env.AUTH_RETURN_URL_ORIGIN ?? request.nextUrl.origin
+ const returnUrl = `${authReturnOrigin.replace(/\/$/, "")}/auth/callback`
+ const apiBase = getPublicApiBase(request.nextUrl.origin)
+ redirect(`${apiBase}/api/auth/login?returnUrl=${encodeURIComponent(returnUrl)}`)
+}
diff --git a/frontend/app/auth/logout/route.ts b/frontend/app/auth/logout/route.ts
new file mode 100644
index 000000000..5d85bce00
--- /dev/null
+++ b/frontend/app/auth/logout/route.ts
@@ -0,0 +1,17 @@
+import { type NextRequest, NextResponse } from "next/server"
+import { getAuthRedirectOrigin } from "../redirect-origin"
+
+export function GET(request: NextRequest) {
+ const redirectOrigin = getAuthRedirectOrigin(request.url)
+ const response = NextResponse.redirect(new URL("/", redirectOrigin))
+ response.cookies.set({
+ name: "atlas_token",
+ value: "",
+ httpOnly: true,
+ sameSite: "lax",
+ secure: request.nextUrl.protocol === "https:",
+ path: "/",
+ expires: new Date(0),
+ })
+ return response
+}
diff --git a/frontend/app/auth/redirect-origin.test.ts b/frontend/app/auth/redirect-origin.test.ts
new file mode 100644
index 000000000..7fa0a7c3d
--- /dev/null
+++ b/frontend/app/auth/redirect-origin.test.ts
@@ -0,0 +1,32 @@
+import type { NextRequest } from "next/server"
+import { afterEach, describe, expect, test } from "vitest"
+import { GET as callbackGet } from "./callback/route"
+import { GET as logoutGet } from "./logout/route"
+
+describe("auth public redirect origin", () => {
+ const originalEnv = process.env.AUTH_RETURN_URL_ORIGIN
+
+ afterEach(() => {
+ if (originalEnv === undefined) {
+ delete process.env.AUTH_RETURN_URL_ORIGIN
+ } else {
+ process.env.AUTH_RETURN_URL_ORIGIN = originalEnv
+ }
+ })
+
+ test("callback redirects to configured public origin after setting token", () => {
+ process.env.AUTH_RETURN_URL_ORIGIN = "https://library.atlas.bi"
+
+ const response = callbackGet(new Request("https://0.0.0.0:3000/auth/callback?token=test-token"))
+
+ expect(response.headers.get("location")).toBe("https://library.atlas.bi/")
+ })
+
+ test("logout redirects to configured public origin", () => {
+ process.env.AUTH_RETURN_URL_ORIGIN = "https://library.atlas.bi"
+
+ const response = logoutGet(new Request("https://0.0.0.0:3000/auth/logout") as NextRequest)
+
+ expect(response.headers.get("location")).toBe("https://library.atlas.bi/")
+ })
+})
diff --git a/frontend/app/auth/redirect-origin.ts b/frontend/app/auth/redirect-origin.ts
new file mode 100644
index 000000000..29e2afe44
--- /dev/null
+++ b/frontend/app/auth/redirect-origin.ts
@@ -0,0 +1,23 @@
+const internalHosts = new Set(["0.0.0.0", "127.0.0.1", "localhost"])
+
+function stripTrailingSlashes(value: string): string {
+ let end = value.length
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
+ end -= 1
+ }
+ return value.slice(0, end)
+}
+
+export function getAuthRedirectOrigin(requestUrl: string): string {
+ const configuredOrigin = process.env.AUTH_RETURN_URL_ORIGIN
+ ? stripTrailingSlashes(process.env.AUTH_RETURN_URL_ORIGIN)
+ : undefined
+ if (configuredOrigin) return configuredOrigin
+
+ const url = new URL(requestUrl)
+ if (internalHosts.has(url.hostname)) {
+ return "http://localhost:3000"
+ }
+
+ return url.origin
+}
diff --git a/frontend/app/collections/actions.ts b/frontend/app/collections/actions.ts
new file mode 100644
index 000000000..4bfd1b290
--- /dev/null
+++ b/frontend/app/collections/actions.ts
@@ -0,0 +1,52 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import { redirect } from "next/navigation"
+import {
+ createCollection,
+ deleteCollection,
+ searchCollectionReports,
+ searchCollectionTerms,
+ updateCollection,
+} from "@/lib/collections/api"
+import type { CollectionWriteBody } from "@/lib/collections/types"
+
+export async function searchCollectionTermsAction(query: string) {
+ return searchCollectionTerms(query)
+}
+
+export async function searchCollectionReportsAction(query: string) {
+ return searchCollectionReports(query)
+}
+
+export async function createCollectionAction(
+ body: CollectionWriteBody,
+): Promise<{ error: string } | undefined> {
+ const result = await createCollection(body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ revalidatePath("/collections")
+ redirect(`/collections?id=${result.data.id}`)
+}
+
+export async function updateCollectionAction(
+ id: number,
+ body: CollectionWriteBody,
+): Promise<{ error: string } | undefined> {
+ const result = await updateCollection(id, body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ revalidatePath("/collections")
+ redirect(`/collections?id=${id}`)
+}
+
+export async function deleteCollectionAction(id: number): Promise<{ error: string } | undefined> {
+ const result = await deleteCollection(id)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ revalidatePath("/collections")
+ redirect("/collections")
+}
diff --git a/frontend/app/collections/edit/page.tsx b/frontend/app/collections/edit/page.tsx
new file mode 100644
index 000000000..4389cf46b
--- /dev/null
+++ b/frontend/app/collections/edit/page.tsx
@@ -0,0 +1,122 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { CollectionForm } from "@/components/collections/collection-form"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { getCurrentUser, getToken, hasPermission } from "@/lib/auth"
+import { getCollectionById } from "@/lib/collections/api"
+
+type EditSearchParams = { id?: string }
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+export default async function EditCollectionPage({
+ searchParams,
+}: {
+ searchParams: EditSearchParams
+}) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const user = await getCurrentUser()
+ if (!user || !hasPermission(user, "Edit Collection")) {
+ return (
+
+
+
+ Permission required
+
+
+
+ You need the Edit Collection permission to update collections.
+
+
+ Back to collections
+
+
+
+
+ )
+ }
+
+ const idRaw = getSingleValue(searchParams.id)
+ const id = idRaw ? Number(idRaw) : NaN
+ if (!Number.isFinite(id) || id <= 0) {
+ return (
+
+
Missing collection
+
+ Provide a valid collection id in the URL.
+
+
+ Back to collections
+
+
+ )
+ }
+
+ const result = await getCollectionById(id)
+ const collection = result.data
+ if (!collection) {
+ return (
+
+
+
+ Unable to load collection
+
+
+
+ Back to collections
+
+
+
+
+ )
+ }
+
+ if (!collection.canEditCollection) {
+ return (
+
+
+
+ Editing not available
+
+
+
+ You do not have access to edit this collection.
+
+
+ Back to collection
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Collections
+
+ /
+
+ {collection.name}
+
+ /
+ Edit
+
+
+
+ )
+}
diff --git a/frontend/app/collections/new/page.tsx b/frontend/app/collections/new/page.tsx
new file mode 100644
index 000000000..7c0aaf1c6
--- /dev/null
+++ b/frontend/app/collections/new/page.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { CollectionForm } from "@/components/collections/collection-form"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { getCurrentUser, getToken, hasPermission } from "@/lib/auth"
+
+export default async function NewCollectionPage() {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const user = await getCurrentUser()
+ if (!user || !hasPermission(user, "Create Collection")) {
+ return (
+
+
+
+ Permission required
+
+
+
+ You need the Create Collection permission to add collections.
+
+
+ Back to collections
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Collections
+
+ /
+ New
+
+
+
+ )
+}
diff --git a/frontend/app/collections/page.tsx b/frontend/app/collections/page.tsx
new file mode 100644
index 000000000..170bfe91b
--- /dev/null
+++ b/frontend/app/collections/page.tsx
@@ -0,0 +1,306 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { CollectionActionRail } from "@/components/collections/collection-action-rail"
+import {
+ CollectionDetailSection,
+ CollectionSubsection,
+} from "@/components/collections/collection-detail-section"
+import { CollectionMetadataTable } from "@/components/collections/collection-metadata-table"
+import { CollectionSectionNav } from "@/components/collections/collection-section-nav"
+import { CollectionsListCard } from "@/components/collections/collections-list-card"
+import { MarkdownContent } from "@/components/content/markdown-content"
+import { LibraryShell } from "@/components/layout/library-shell"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import { InitiativeSnippetCard } from "@/components/snippets/initiative-snippet-card"
+import { ReportSnippetCard } from "@/components/snippets/report-snippet-card"
+import { TermSnippetCard } from "@/components/snippets/term-snippet-card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { type AuthUser, getCurrentUser, getToken, hasPermission } from "@/lib/auth"
+import { getCollectionById, getCollectionsList } from "@/lib/collections/api"
+import type { CollectionReportDto, CollectionTermDto } from "@/lib/collections/types"
+import { getUserFriendlyErrorMessage } from "@/lib/errors"
+
+type CollectionsSearchParams = {
+ id?: string
+ page?: string
+ pageSize?: string
+}
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+function asPositiveInt(raw: string | undefined, fallback: number): number {
+ const parsed = Number(raw)
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback
+}
+
+function buildListHref(page: number, pageSize: number): string {
+ const params = new URLSearchParams()
+ params.set("page", String(page))
+ params.set("pageSize", String(pageSize))
+ return `/collections?${params.toString()}`
+}
+
+function sortByRank(items: T[]): T[] {
+ return [...items].sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
+}
+
+function resolveDisplayName(user: AuthUser | null): string {
+ if (!user) return "Guest"
+ if (user.fullname && user.fullname !== "Guest") return user.fullname
+ return user.username || "Guest"
+}
+
+function getShellProps(user: AuthUser | null) {
+ return {
+ displayName: resolveDisplayName(user),
+ isSignedIn: !!user,
+ isAdministrator: !!user && user.roles.includes("Administrator"),
+ adminEnabled: user?.adminEnabled ?? false,
+ }
+}
+
+export default async function CollectionsPage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const resolvedSearchParams = await searchParams
+ const user = await getCurrentUser()
+ const shellProps = getShellProps(user)
+ const canCreateCollection = !!user && hasPermission(user, "Create Collection")
+
+ const idRaw = getSingleValue(resolvedSearchParams.id)
+ if (idRaw) {
+ const id = Number(idRaw)
+ if (!Number.isFinite(id) || id <= 0) {
+ return (
+
+ Collection not found
+
+ Missing or invalid collection id.
+
+
+ Back to collections
+
+
+ )
+ }
+
+ const result = await getCollectionById(id)
+ const collection = result.data
+ if (!collection) {
+ const message = getUserFriendlyErrorMessage(result.error ?? "unknown")
+ return (
+
+
+
+ Unable to load collection
+
+
+ {message}
+
+ Back to collections
+
+
+
+
+ )
+ }
+
+ const features = collection.features ?? {}
+ const termsEnabled = features.termsEnabled !== false
+ const sortedReports = sortByRank(collection.reports ?? []) as CollectionReportDto[]
+ const sortedTerms = sortByRank(collection.terms ?? []) as CollectionTermDto[]
+ const hasInitiative = !!collection.initiative?.id
+ const hasReports = sortedReports.length > 0
+ const hasTerms = termsEnabled && sortedTerms.length > 0
+
+ return (
+
+
+
+
+ Collections
+
+
+
+
{collection.name}
+ {collection.hidden === "Y" ? (
+
+ Hidden from search
+
+ ) : null}
+
+
+
+
+
+
}
+ />
+
+
+ {hasInitiative && collection.initiative ? (
+
+
+
+
+
+ ) : null}
+
+ {hasReports ? (
+
+
+ {sortedReports.map((report) => (
+
+ ))}
+
+
+ ) : null}
+
+ {hasTerms ? (
+
+
+ {sortedTerms.map((term) => {
+ const key =
+ typeof term.id === "number"
+ ? term.id
+ : typeof term.termId === "number"
+ ? term.termId
+ : `${term.name ?? "term"}-${term.rank ?? 0}`
+ return
+ })}
+
+
+ ) : null}
+
+
+
+ {collection.description ? (
+
+
+
+ ) : null}
+
+ {collection.purpose ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ )
+ }
+
+ const page = asPositiveInt(getSingleValue(resolvedSearchParams.page), 1)
+ const pageSize = Math.min(100, asPositiveInt(getSingleValue(resolvedSearchParams.pageSize), 20))
+ const listResult = await getCollectionsList(page, pageSize)
+ const list = listResult.data
+
+ if (!list) {
+ const message = getUserFriendlyErrorMessage(listResult.error ?? "unknown")
+ const statusHint =
+ listResult.status != null ? `The API responded with HTTP ${listResult.status}.` : null
+ const backendHint =
+ listResult.error === "server_error"
+ ? "The Collections API on the C# backend returned an error. Confirm the API is running on the feat/collections-api branch (or newer) and check backend logs for GET /api/collections."
+ : listResult.error === "service_unavailable"
+ ? "API_URL is not configured. Set API_URL in .env.local to your Library backend (e.g. http://localhost:5000)."
+ : null
+ return (
+
+
+
+ Unable to load collections
+
+
+ {message}
+ {statusHint ? {statusHint}
: null}
+ {backendHint ? {backendHint}
: null}
+
+ Back to home
+
+
+
+
+ )
+ }
+
+ const totalPages = Math.max(1, Math.ceil(list.total / list.pageSize))
+
+ return (
+
+
+
+ {list.collections.length === 0 ? (
+ No collections found.
+ ) : (
+
+ {list.collections.map((item) => (
+
+ ))}
+
+ )}
+
+ {list.total > list.pageSize ? (
+
+
+ {list.total} total • page {list.page} of {totalPages}
+
+
+ {page <= 1 ? (
+
+ Previous
+
+ ) : (
+
+ Previous
+
+ )}
+ {page >= totalPages ? (
+
+ Next
+
+ ) : (
+
+ Next
+
+ )}
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx
new file mode 100644
index 000000000..4757503fb
--- /dev/null
+++ b/frontend/app/error.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import Link from "next/link"
+import { useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { reportError } from "@/lib/error-reporting"
+
+export default function ErrorPage({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ useEffect(() => {
+ reportError(error, { boundary: "app/error" })
+ }, [error])
+
+ return (
+
+
+
+ Something went wrong
+
+
+
+ We could not complete this action. Please try again.
+
+
+ Try again
+
+ Back to home
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico
new file mode 100644
index 000000000..d763a547d
Binary files /dev/null and b/frontend/app/favicon.ico differ
diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx
new file mode 100644
index 000000000..e27317d50
--- /dev/null
+++ b/frontend/app/global-error.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { reportError } from "@/lib/error-reporting"
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ reportError(error, { boundary: "app/global-error" })
+
+ return (
+
+
+
+
+
Application error
+
+ An unexpected error occurred. Please retry or return to the home page.
+
+
+ Retry
+
+ Home
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 000000000..acd65fb19
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,466 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --primary: oklch(0.205 0 0);
+ --success: #48c78e;
+ --warning: #ffe08a;
+ --danger: #f14668;
+ --info: #3e8ed0;
+ --link: #485fc7;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --chart-3: oklch(0.546 0.245 262.881);
+ --chart-4: oklch(0.488 0.243 264.376);
+ --chart-5: oklch(0.424 0.199 265.638);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+ --atlas-home-text: #4a4a4a;
+ --atlas-home-text-strong: #363636;
+ --atlas-home-body-size: 0.875rem;
+ --atlas-home-body-weight: 500;
+ --atlas-home-title: #363636;
+ --atlas-home-link: #485fc7;
+ --atlas-home-link-hover: #363636;
+ --atlas-home-muted: #7a7a7a;
+ --atlas-home-muted-light: #b5b5b5;
+ --atlas-home-border: #dbdbdb;
+ --atlas-home-border-soft: #ededed;
+ --atlas-home-logo-text: #334155;
+ --atlas-home-logo-hover: #0f172a;
+ --atlas-home-logo-slash: #cbd5e1;
+ --atlas-home-search-icon: #d3d6db;
+ --atlas-home-shadow:
+ 0 0.5em 1em -0.125em hsla(0, 0%, 4%, 0.1),
+ 0 0 0 1px hsla(0, 0%, 4%, 0.02);
+ --atlas-home-warning: #ffe08a;
+ --atlas-home-primary: #00d1b2;
+ --atlas-home-success: #48c78e;
+ --atlas-home-star: #ffd700;
+ --atlas-home-surface-muted: #f5f5f5;
+ --atlas-home-thumbnail-start: #dbeafe;
+ --atlas-home-thumbnail-end: #bfdbfe;
+ --atlas-home-thumbnail-accent: rgba(72, 199, 142, 0.2);
+ --atlas-home-navbar-height: 3.5rem;
+ --atlas-home-title-size: 2.5rem;
+ --atlas-home-card-footer-padding: 0.5rem 0.75rem;
+ --atlas-action-rail-width: 4.75rem;
+ --atlas-action-rail-sticky-top: calc(var(--atlas-home-navbar-height) + 1.5rem);
+ --atlas-home-spacing-1: 0.5rem;
+ --atlas-home-spacing-2: 0.75rem;
+ --atlas-home-spacing-3: 0.875rem;
+ --atlas-home-spacing-4: 0.875rem;
+ --atlas-home-radius: 6px;
+ --atlas-home-footer-background: #fafafa;
+ --atlas-home-dropdown-shadow: 0 8px 8px hsla(0, 0%, 4%, 0.1);
+ --atlas-home-content-max-width: 1280px;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-inter);
+ --font-mono: var(--font-source-code-pro);
+ --font-serif: var(--font-rasa);
+
+ --color-primary: var(--primary);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-danger: var(--danger);
+ --color-info: var(--info);
+ --color-link: var(--link);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --chart-3: oklch(0.546 0.245 262.881);
+ --chart-4: oklch(0.488 0.243 264.376);
+ --chart-5: oklch(0.424 0.199 265.638);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
+
+@layer components {
+ .atlas-home-surface {
+ color: var(--atlas-home-text);
+ font-size: var(--atlas-home-body-size);
+ font-weight: var(--atlas-home-body-weight);
+ line-height: 1.5;
+ }
+
+ .atlas-home-card {
+ border-radius: var(--atlas-home-radius);
+ background: white;
+ box-shadow: var(--atlas-home-shadow);
+ }
+
+ .atlas-snippet-gold-card {
+ border: 1px solid #ffd700;
+ border-radius: var(--atlas-home-radius);
+ background: white;
+ box-shadow:
+ 0 0.5em 1em -0.125em rgba(255, 215, 0, 0.1),
+ 0 0 0 1px rgba(255, 215, 0, 0.02);
+ }
+
+ .atlas-home-heading {
+ color: var(--atlas-home-title);
+ font-family: var(--font-rasa), Georgia, "Times New Roman", serif;
+ font-size: var(--atlas-home-title-size);
+ font-weight: 700;
+ line-height: 1.2;
+ margin-bottom: 1rem;
+ }
+
+ .atlas-home-card-title {
+ color: var(--atlas-home-text-strong);
+ font-size: var(--atlas-home-body-size);
+ font-weight: 700;
+ line-height: 1.5;
+ }
+
+ .atlas-home-card-title:hover {
+ color: var(--atlas-home-text-strong);
+ text-decoration: underline;
+ }
+
+ .atlas-home-brand {
+ color: var(--atlas-home-logo-text);
+ align-items: center;
+ font-weight: 500;
+ }
+
+ .atlas-home-brand:hover {
+ color: var(--atlas-home-logo-hover);
+ }
+
+ .atlas-home-brand-slash {
+ color: var(--atlas-home-logo-slash);
+ }
+
+ .atlas-home-search-shell {
+ border: 1px solid var(--atlas-home-border);
+ color: var(--atlas-home-muted);
+ border-radius: 6px;
+ font-size: 0.8125rem;
+ }
+
+ .atlas-home-search-shell::placeholder {
+ color: var(--atlas-home-muted-light);
+ }
+
+ .atlas-home-search-icon {
+ color: var(--atlas-home-search-icon);
+ }
+
+ .atlas-home-toolbar-button {
+ color: var(--atlas-home-text);
+ }
+
+ .atlas-home-navbar-link {
+ color: var(--atlas-home-text);
+ border-radius: 0;
+ height: 100%;
+ }
+
+ .atlas-home-navbar-link:hover {
+ color: var(--atlas-home-title);
+ background-color: transparent;
+ }
+
+ .atlas-home-dropdown {
+ border-radius: 0;
+ border: 1px solid var(--atlas-home-border-soft);
+ background: white;
+ box-shadow: var(--atlas-home-dropdown-shadow);
+ min-width: 12rem;
+ margin-top: 0.125rem;
+ }
+
+ .atlas-home-dropdown-item {
+ color: var(--atlas-home-text);
+ line-height: 1.5;
+ white-space: nowrap;
+ }
+
+ .atlas-home-dropdown-item:hover {
+ background-color: #f5f5f5;
+ color: #0a0a0a;
+ }
+
+ .atlas-home-dropdown-divider {
+ border: 0;
+ border-top: 1px solid var(--atlas-home-border-soft);
+ }
+
+ .atlas-home-admin-toggle {
+ transition:
+ background-color 150ms ease,
+ box-shadow 150ms ease;
+ }
+
+ .atlas-home-admin-toggle:hover {
+ box-shadow: inset 0 0 0 1px rgba(10, 10, 10, 0.08);
+ }
+
+ .atlas-home-tab-nav {
+ color: var(--atlas-home-link);
+ font-size: 0.9rem;
+ font-weight: var(--atlas-home-body-weight);
+ margin-bottom: 1.25rem;
+ }
+
+ .atlas-home-tab-link {
+ color: var(--atlas-home-link);
+ font-weight: var(--atlas-home-body-weight);
+ }
+
+ .atlas-home-tab-link-active {
+ color: var(--atlas-home-text-strong);
+ font-weight: 600;
+ }
+
+ .atlas-home-footer-subtitle {
+ color: var(--atlas-home-text-strong);
+ font-size: var(--atlas-home-body-size);
+ font-weight: var(--atlas-home-body-weight);
+ line-height: 1.5;
+ }
+
+ .atlas-home-footer-copyright {
+ color: var(--atlas-home-muted);
+ font-size: var(--atlas-home-body-size);
+ font-weight: var(--atlas-home-body-weight);
+ line-height: 1.5;
+ }
+
+ .atlas-home-footer-heading {
+ color: var(--atlas-home-text-strong);
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.5;
+ }
+
+ .atlas-home-footer-link {
+ color: var(--atlas-home-link);
+ font-size: var(--atlas-home-body-size);
+ font-weight: var(--atlas-home-body-weight);
+ line-height: 1.5;
+ }
+
+ .atlas-home-footer-link:hover {
+ color: var(--atlas-home-link-hover);
+ text-decoration: underline;
+ }
+
+ .atlas-home-filter-button {
+ border: 1px solid var(--atlas-home-warning);
+ color: rgba(0, 0, 0, 0.7);
+ background: white;
+ }
+
+ .atlas-home-folder-badge {
+ background: var(--atlas-home-primary);
+ color: white;
+ }
+
+ .atlas-home-type-pill {
+ background: var(--atlas-home-surface-muted);
+ color: var(--atlas-home-text);
+ }
+
+ .atlas-home-thumbnail {
+ background:
+ linear-gradient(
+ 135deg,
+ var(--atlas-home-thumbnail-accent),
+ rgba(72, 199, 142, 0)
+ ),
+ linear-gradient(
+ 180deg,
+ var(--atlas-home-thumbnail-start) 0%,
+ var(--atlas-home-thumbnail-end) 100%
+ );
+ }
+
+ .atlas-home-footer-cell {
+ padding: var(--atlas-home-card-footer-padding);
+ color: var(--atlas-home-text);
+ }
+
+ .atlas-home-star-icon {
+ color: var(--atlas-home-star);
+ fill: var(--atlas-home-star);
+ }
+
+ .atlas-action-rail {
+ top: var(--atlas-action-rail-sticky-top);
+ display: flex;
+ width: var(--atlas-action-rail-width);
+ flex-direction: column;
+ align-items: center;
+ gap: 0.375rem;
+ border-radius: var(--atlas-home-radius);
+ border: 1px solid var(--atlas-home-border-soft);
+ background: rgba(255, 255, 255, 0.96);
+ padding: 0.625rem 0.5rem;
+ box-shadow: 0 10px 24px rgba(10, 10, 10, 0.08);
+ }
+
+ .atlas-action-rail-label {
+ margin-bottom: 0.25rem;
+ width: 100%;
+ border-bottom: 1px solid var(--atlas-home-border-soft);
+ padding-bottom: 0.5rem;
+ text-align: center;
+ font-size: 0.625rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--atlas-home-muted);
+ }
+
+ .atlas-action-rail-button {
+ height: 2.5rem;
+ width: 2.5rem;
+ color: var(--atlas-home-muted);
+ }
+
+ .atlas-action-rail-button:hover {
+ color: var(--atlas-home-link);
+ }
+
+ .atlas-action-rail-group-separated {
+ margin-top: 0.125rem;
+ border-top: 1px solid var(--atlas-home-border-soft);
+ }
+
+ .atlas-footer-icon-group {
+ color: var(--atlas-home-muted);
+ }
+
+ .atlas-home-table {
+ width: 100%;
+ text-align: left;
+ font-size: 0.875rem;
+ color: var(--atlas-home-text);
+ }
+
+ .atlas-home-table thead {
+ color: var(--atlas-home-text-strong);
+ font-weight: 600;
+ }
+
+ .atlas-home-table th,
+ .atlas-home-table td {
+ padding: 0.75rem 1rem;
+ vertical-align: top;
+ }
+
+ .atlas-home-table tbody tr {
+ border-top: 1px solid var(--atlas-home-border-soft);
+ }
+
+ .atlas-home-footer {
+ background: var(--atlas-home-footer-background);
+ }
+}
diff --git a/frontend/app/groups/page.tsx b/frontend/app/groups/page.tsx
new file mode 100644
index 000000000..c24cff281
--- /dev/null
+++ b/frontend/app/groups/page.tsx
@@ -0,0 +1,192 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { GroupDetailsTable } from "@/components/groups/group-details-table"
+import { GroupPageTabs } from "@/components/groups/group-page-tabs"
+import { GroupReportsTable } from "@/components/groups/group-reports-table"
+import { GroupRunListPanel } from "@/components/groups/group-run-list-panel"
+import { GroupUsersTable } from "@/components/groups/group-users-table"
+import { HomeGroupsPanelView } from "@/components/home/home-groups-panel"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { getCurrentUser, getToken } from "@/lib/auth"
+import { getUserFriendlyErrorMessage } from "@/lib/errors"
+import { getGroupById, getGroupReports, getGroupsList, getGroupUsers } from "@/lib/groups/api"
+import { getHomeUserPageSummary } from "@/lib/home/api"
+
+type GroupsSearchParams = {
+ id?: string
+}
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+export default async function GroupsPage({ searchParams }: { searchParams: GroupsSearchParams }) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const idRaw = getSingleValue(searchParams.id)
+
+ if (!idRaw) {
+ return
+ }
+
+ const id = Number(idRaw)
+ if (!Number.isFinite(id) || id <= 0) {
+ return (
+
+
Group not found
+
Missing or invalid group id.
+
+ Back to groups
+
+
+ )
+ }
+
+ const [groupResult, usersResult, reportsResult, user] = await Promise.all([
+ getGroupById(id),
+ getGroupUsers(id),
+ getGroupReports(id),
+ getCurrentUser(),
+ ])
+
+ const group = groupResult.data
+ if (!group) {
+ const message = getUserFriendlyErrorMessage(groupResult.error ?? "unknown")
+ const denied = groupResult.error === "forbidden" || groupResult.status === 403
+
+ return (
+
+
+
+
+ {denied ? "Access denied" : "Unable to load group"}
+
+
+
+
+ {denied ? "You do not have access to this page." : message}
+
+
+ Back to groups
+
+
+
+
+ )
+ }
+
+ if (group.permissions?.canViewGroups === false) {
+ return (
+
+
You do not have access to this page.
+
+ )
+ }
+
+ const userId = user?.userId ? Number(user.userId) : NaN
+ const summaryResult =
+ Number.isFinite(userId) && userId > 0 ? await getHomeUserPageSummary(userId) : null
+ const reportTypeIds = summaryResult?.data?.defaultReportTypeIds ?? []
+ const showAnalytics = group.permissions?.canViewSiteAnalytics === true
+
+ const users = usersResult.data ?? []
+ const reports = reportsResult.data ?? []
+
+ return (
+
+
+
+
+
+ Groups
+
+
+
+ {group.name?.trim() || `Group ${group.id}`}
+
+
+
+
}
+ users={
}
+ reports={
}
+ activity={
}
+ runList={
}
+ analytics={
+
+
+ Site analytics for this group are available in the Analytics application.
+
+
+ Open Analytics
+
+
+ }
+ />
+
+
+ )
+}
+
+async function GroupsListView() {
+ const result = await getGroupsList()
+ const list = result.data
+
+ if (!list) {
+ const message = getUserFriendlyErrorMessage(result.error ?? "unknown")
+ const denied = result.error === "forbidden" || result.status === 403
+
+ return (
+
+
+
+
+ {denied ? "Access denied" : "Unable to load groups"}
+
+
+
+
+ {denied ? "You do not have access to this page." : message}
+
+
+
+
+ )
+ }
+
+ if (list.permissions?.canViewGroups === false) {
+ return (
+
+
You do not have access to this page.
+
+ )
+ }
+
+ const panel = {
+ kind: "groups" as const,
+ title: "Groups",
+ emptyMessage: "No groups to show.",
+ rows: (list.items ?? []).map((item) => ({
+ id: String(item.id),
+ name: item.name?.trim() || `Group ${item.id}`,
+ type: item.type?.trim() || undefined,
+ source: item.source?.trim() || undefined,
+ href: item.url ?? `/groups?id=${item.id}`,
+ })),
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/frontend/app/interactions/actions.ts b/frontend/app/interactions/actions.ts
new file mode 100644
index 000000000..48c11a6bc
--- /dev/null
+++ b/frontend/app/interactions/actions.ts
@@ -0,0 +1,60 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import {
+ searchInteractionRecipients,
+ sendShareMail,
+ submitAccessRequest,
+ submitFeedback,
+ toggleStar,
+} from "@/lib/interactions/api"
+import type {
+ AccessRequestRequest,
+ FeedbackRequest,
+ InteractionEntityType,
+ ShareMailRequest,
+} from "@/lib/interactions/types"
+
+export async function toggleStarAction(type: InteractionEntityType, id: number) {
+ const result = await toggleStar({ type, id })
+ if (!result.ok) {
+ return { error: result.message }
+ }
+
+ revalidatePath("/")
+ if (type === "report") {
+ revalidatePath("/reports")
+ } else {
+ revalidatePath("/collections")
+ }
+
+ return { data: result.data }
+}
+
+export async function searchRecipientsAction(query: string, includeGroups = true) {
+ return searchInteractionRecipients(query, includeGroups)
+}
+
+export async function sendShareMailAction(body: ShareMailRequest) {
+ const result = await sendShareMail(body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ return { data: result.data }
+}
+
+export async function submitFeedbackAction(body: FeedbackRequest) {
+ const result = await submitFeedback(body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ return { data: result.data }
+}
+
+export async function submitAccessRequestAction(body: AccessRequestRequest) {
+ const result = await submitAccessRequest(body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ return { data: result.data }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 000000000..60611280f
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,50 @@
+import type { Metadata } from "next"
+import { Inter, Rasa, Source_Code_Pro } from "next/font/google"
+import { ThemeProvider } from "@/components/theme-provider"
+import { Toaster } from "@/components/ui/sonner"
+import { TooltipProvider } from "@/components/ui/tooltip"
+import "./globals.css"
+
+const inter = Inter({
+ variable: "--font-inter",
+ subsets: ["latin"],
+ weight: ["400", "500", "600", "700"],
+})
+
+const sourceCodePro = Source_Code_Pro({
+ variable: "--font-source-code-pro",
+ subsets: ["latin"],
+})
+
+const rasa = Rasa({
+ variable: "--font-rasa",
+ subsets: ["latin"],
+ weight: ["600", "700"],
+})
+
+export const metadata: Metadata = {
+ title: {
+ template: "%s | Atlas BI Library",
+ default: "Atlas BI Library",
+ },
+ description: "Atlas BI Library",
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode
+}>) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 000000000..18fd54ee0
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,41 @@
+import { HomeShell } from "@/components/home/home-shell"
+import { getCurrentUser } from "@/lib/auth"
+import { getHomeUserPageSummary } from "@/lib/home/api"
+import type { HomeTabsVisibility } from "@/lib/home/types"
+
+const allTabsVisible: HomeTabsVisibility = {
+ stars: true,
+ subscriptions: true,
+ "report-runs": true,
+ groups: true,
+}
+
+export default async function HomePage() {
+ const user = await getCurrentUser()
+
+ if (!user) {
+ return (
+
+ )
+ }
+
+ const userId = Number(user.userId)
+ const page = Number.isFinite(userId) && userId > 0 ? await getHomeUserPageSummary(userId) : null
+ const displayName = user.fullname || page?.data?.displayName || user.username || "Guest"
+
+ return (
+
+ )
+}
diff --git a/frontend/app/profile/actions.ts b/frontend/app/profile/actions.ts
new file mode 100644
index 000000000..9c90a14c9
--- /dev/null
+++ b/frontend/app/profile/actions.ts
@@ -0,0 +1,78 @@
+"use server"
+
+import {
+ getProfileChart,
+ getProfileFails,
+ getProfileReports,
+ getProfileRunList,
+ getProfileStars,
+ getProfileSubscriptions,
+ getProfileUsers,
+} from "@/lib/profile/api"
+import type {
+ ProfileBarItemDto,
+ ProfileChartResponseDto,
+ ProfileFilters,
+ ProfileRunListItemDto,
+ ProfileStarUserDto,
+ ProfileSubscriptionDto,
+} from "@/lib/profile/types"
+
+export type ProfileAnalyticsData = {
+ chart: ProfileChartResponseDto | null
+ users: ProfileBarItemDto[]
+ reports: ProfileBarItemDto[]
+ fails: ProfileBarItemDto[]
+ runList: ProfileRunListItemDto[]
+ stars: ProfileStarUserDto[]
+ subscriptions: ProfileSubscriptionDto[]
+}
+
+export async function loadProfileAnalyticsAction(
+ id: number,
+ type: string,
+ options?: Partial>,
+): Promise<{ data: ProfileAnalyticsData | null; error: string | null }> {
+ const filters: ProfileFilters = { id, type, ...options }
+ const canLoadProfileRelationships = type !== "user"
+ const [
+ chartResult,
+ usersResult,
+ reportsResult,
+ failsResult,
+ runListResult,
+ starsResult,
+ subsResult,
+ ] = await Promise.all([
+ getProfileChart(filters),
+ getProfileUsers(filters),
+ getProfileReports(filters),
+ getProfileFails(filters),
+ getProfileRunList(filters),
+ canLoadProfileRelationships ? getProfileStars(filters) : Promise.resolve({ data: [], error: null }),
+ canLoadProfileRelationships
+ ? getProfileSubscriptions(filters)
+ : Promise.resolve({ data: [], error: null }),
+ ])
+
+ if (
+ chartResult.error === "auth_required" ||
+ usersResult.error === "auth_required" ||
+ reportsResult.error === "auth_required"
+ ) {
+ return { data: null, error: "auth_required" }
+ }
+
+ return {
+ data: {
+ chart: chartResult.data,
+ users: usersResult.data ?? [],
+ reports: reportsResult.data ?? [],
+ fails: failsResult.data ?? [],
+ runList: runListResult.data ?? [],
+ stars: starsResult.data ?? [],
+ subscriptions: subsResult.data ?? [],
+ },
+ error: null,
+ }
+}
diff --git a/frontend/app/reports/actions.ts b/frontend/app/reports/actions.ts
new file mode 100644
index 000000000..88c72d1df
--- /dev/null
+++ b/frontend/app/reports/actions.ts
@@ -0,0 +1,56 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import { redirect } from "next/navigation"
+import {
+ searchReportCollections,
+ searchReportTerms,
+ searchReportUsers,
+ updateReport,
+ uploadReportImage,
+} from "@/lib/reports/api"
+import type { ReportUpdateBody } from "@/lib/reports/types"
+
+export async function searchReportTermsAction(query: string) {
+ return searchReportTerms(query)
+}
+
+export async function searchReportCollectionsAction(query: string) {
+ return searchReportCollections(query)
+}
+
+export async function searchReportUsersAction(query: string) {
+ return searchReportUsers(query)
+}
+
+export async function updateReportAction(
+ id: number,
+ body: ReportUpdateBody,
+): Promise<{ error: string } | undefined> {
+ const result = await updateReport(id, body)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ revalidatePath("/reports")
+ redirect(`/reports?id=${id}`)
+}
+
+export async function uploadReportImageAction(
+ id: number,
+ formData: FormData,
+): Promise<{ error: string } | { ok: true }> {
+ const file = formData.get("file")
+ if (!(file instanceof File) || file.size === 0) {
+ return { error: "Please choose an image file." }
+ }
+ if (file.size > 1024 * 1024) {
+ return { error: "Image must be 1 MB or smaller." }
+ }
+
+ const result = await uploadReportImage(id, file)
+ if (!result.ok) {
+ return { error: result.message }
+ }
+ revalidatePath("/reports")
+ return { ok: true }
+}
diff --git a/frontend/app/reports/edit/page.tsx b/frontend/app/reports/edit/page.tsx
new file mode 100644
index 000000000..bc91a8f59
--- /dev/null
+++ b/frontend/app/reports/edit/page.tsx
@@ -0,0 +1,122 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { ReportEditForm } from "@/components/reports/report-edit-form"
+import { ReportImageUpload } from "@/components/reports/report-image-upload"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { getCurrentUser, getToken, hasPermission } from "@/lib/auth"
+import { getReportDetailById } from "@/lib/reports/api"
+
+type EditSearchParams = { id?: string }
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+export default async function EditReportPage({ searchParams }: { searchParams: EditSearchParams }) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const user = await getCurrentUser()
+ if (!user || !hasPermission(user, "Edit Report Documentation")) {
+ return (
+
+
+
+ Permission required
+
+
+
+ You need the Edit Report Documentation permission to update reports.
+
+
+ Back to home
+
+
+
+
+ )
+ }
+
+ const idRaw = getSingleValue(searchParams.id)
+ const id = idRaw ? Number(idRaw) : NaN
+ if (!Number.isFinite(id) || id <= 0) {
+ return (
+
+
Missing report
+
Provide a valid report id in the URL.
+
+ Back to home
+
+
+ )
+ }
+
+ const result = await getReportDetailById(id)
+ const report = result.data
+ if (!report) {
+ return (
+
+
+
+ Unable to load report
+
+
+
+ Back to home
+
+
+
+
+ )
+ }
+
+ if (!report.canEditDocumentation) {
+ return (
+
+
+
+ Editing not available
+
+
+
+ You do not have access to edit this report.
+
+
+ Back to report
+
+
+
+
+ )
+ }
+
+ const title = report.displayTitle || report.displayName || report.name
+
+ return (
+
+
+
+ {title}
+
+ /
+ Edit documentation
+
+
+
+
+ Images
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/reports/page.tsx b/frontend/app/reports/page.tsx
new file mode 100644
index 000000000..657677cec
--- /dev/null
+++ b/frontend/app/reports/page.tsx
@@ -0,0 +1,358 @@
+import Image from "next/image"
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { LibraryShell } from "@/components/layout/library-shell"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import { ReportActionRail } from "@/components/reports/report-action-rail"
+import { AppAlertDialog } from "@/components/ui/app-alert-dialog"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { type AuthUser, getCurrentUser, getToken } from "@/lib/auth"
+import { getUserFriendlyErrorMessage } from "@/lib/errors"
+import { getReportDetailById } from "@/lib/reports/api"
+import type { ReportDetail } from "@/lib/reports/types"
+
+type ReportsSearchParams = {
+ id?: string
+}
+
+function formatReportTitle(report: ReportDetail) {
+ return report.displayTitle || report.displayName || report.name
+}
+
+function getFullName(person?: { fullName?: string | null } | null) {
+ return (person?.fullName ?? "").trim()
+}
+
+function resolveDisplayName(user: AuthUser | null): string {
+ if (!user) return "Guest"
+ if (user.fullname && user.fullname !== "Guest") return user.fullname
+ return user.username || "Guest"
+}
+
+function getShellProps(user: AuthUser | null) {
+ return {
+ displayName: resolveDisplayName(user),
+ isSignedIn: !!user,
+ isAdministrator: !!user && user.roles.includes("Administrator"),
+ adminEnabled: user?.adminEnabled ?? false,
+ }
+}
+
+export default async function ReportsPage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const resolvedSearchParams = await searchParams
+ const user = await getCurrentUser()
+ const shellProps = getShellProps(user)
+
+ const idRaw = resolvedSearchParams.id
+ const id = idRaw ? Number(idRaw) : NaN
+ if (!Number.isFinite(id) || id <= 0) {
+ return (
+
+ Report not found
+ Missing or invalid report id.
+
+ Back to home
+
+
+ )
+ }
+
+ const result = await getReportDetailById(id)
+ const report = result.data
+ if (!report) {
+ const message = getUserFriendlyErrorMessage(result.error ?? "unknown")
+ return (
+
+
+
+ Unable to load report
+
+
+ {message}
+
+
+
+
+ )
+ }
+
+ const title = formatReportTitle(report)
+
+ return (
+
+
+
+
+ Home
+
+
+
+
{title}
+
+ {report.typeShortName ? {report.typeShortName} : null}
+ {report.availability ? • {report.availability} : null}
+
+ {Array.isArray(report.headerTags) && report.headerTags.length > 0 ? (
+
+ {report.headerTags
+ .filter((tag) => tag.showInHeader === true || tag.showInHeader === "Y")
+ .map((tag) => (
+
+ {tag.name ?? `Tag ${tag.id}`}
+
+ ))}
+
+ ) : null}
+ {report.description || report.detailedDescription ? (
+
+ {report.detailedDescription || report.description}
+
+ ) : null}
+
+
+
+
+
}
+ />
+
+
+ {report.maintenanceStatus?.isRequired ? (
+
+
+ Maintenance required
+
+
+ {report.maintenanceStatus.message ?? "Maintenance is required."}
+ {report.maintenanceStatus.nextMaintenanceDate ? (
+
+ Next maintenance:{" "}
+ {new Date(report.maintenanceStatus.nextMaintenanceDate).toLocaleDateString()}
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+ Details
+
+
+ {report.lastModified ? (
+
+ Last modified: {new Date(report.lastModified).toLocaleString()}
+
+ ) : null}
+
+ {report.author ? (
+
+ Author:{" "}
+ {report.features?.userProfilesEnabled && report.canViewUserProfiles ? (
+
+ {getFullName(report.author) || report.author.username}
+
+ ) : (
+ getFullName(report.author) || report.author.username
+ )}
+
+ ) : null}
+
+ {report.lastModifiedBy ? (
+
+ Last modified by:{" "}
+ {getFullName(report.lastModifiedBy) || report.lastModifiedBy.username}
+
+ ) : null}
+
+ {report.requester ? (
+
+ Requester:{" "}
+ {report.features?.userProfilesEnabled && report.canViewUserProfiles ? (
+
+ {getFullName(report.requester) || report.requester.username}
+
+ ) : (
+ getFullName(report.requester) || report.requester.username
+ )}
+
+ ) : null}
+
+ {typeof report.runs === "number" ? (
+ Runs: {report.runs}
+ ) : null}
+
+
+
+ {report.features?.termsEnabled !== false && report.terms && report.terms.length > 0 ? (
+
+
+ Terms
+
+
+
+ {report.terms.map((term) => (
+
+ {term.name ?? term.summary ?? `Term ${term.id}`}
+
+ ))}
+
+
+
+ ) : null}
+
+ {report.canViewGroups && Array.isArray(report.groups) && report.groups.length > 0 ? (
+
+
+ Groups
+
+
+
+ {report.groups.map((group) => (
+
+
+ {group.name ?? group.email ?? `Group ${group.id}`}
+
+
+ ))}
+
+
+
+ ) : null}
+
+ {Array.isArray(report.parents) || Array.isArray(report.children) ? (
+
+
+ Relationships
+
+
+ {Array.isArray(report.parents) && report.parents.length > 0 ? (
+
+
Parents
+
+ {report.parents.map((parent) => (
+
+
+ {parent.name ?? parent.displayTitle ?? parent.type ?? "Report"}
+
+
+ ))}
+
+
+ ) : null}
+ {Array.isArray(report.children) && report.children.length > 0 ? (
+
+
Children
+
+ {report.children.map((child) => (
+
+
+ {child.name ?? child.displayTitle ?? child.type ?? "Report"}
+
+
+ ))}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {Array.isArray(report.queries) && report.queries.length > 0 ? (
+
+
+ Queries
+
+
+
+
+
+ ) : null}
+
+ {report.componentQueries && report.componentQueries.length > 0 ? (
+
+
+ Component Queries
+
+
+
+
+
+ ) : null}
+
+ {Array.isArray(report.images) && report.images.length > 0 ? (
+
+
+ Images
+
+
+
+ {report.images.map((image) => (
+
+ {image.source ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx
new file mode 100644
index 000000000..9be711d0d
--- /dev/null
+++ b/frontend/app/search/page.tsx
@@ -0,0 +1,420 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import type { ReactNode } from "react"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { getCurrentUser, getToken, hasPermission } from "@/lib/auth"
+import { getUserFriendlyErrorMessage } from "@/lib/errors"
+import { searchLibrary } from "@/lib/search/api"
+import { SEARCH_TYPES, type SearchResultDto, type SearchType } from "@/lib/search/types"
+
+type SearchPageParams = Record
+
+const RESERVED_KEYS = new Set(["q", "type", "page", "pageSize", "field", "advanced"])
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+function asPositiveInt(raw: string | undefined, fallback: number): number {
+ const parsed = Number(raw)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
+}
+
+function normalizeSearchType(raw: string | undefined): SearchType {
+ if (!raw) return "query"
+ return SEARCH_TYPES.includes(raw as SearchType) ? (raw as SearchType) : "query"
+}
+
+function toHrefFromParams(params: URLSearchParams): string {
+ const query = params.toString()
+ return query ? `/search?${query}` : "/search"
+}
+
+function buildResultHref(result: SearchResultDto): string {
+ switch (result.type) {
+ case "reports":
+ return `/reports?id=${result.atlasId}`
+ case "collections":
+ return `/collections?id=${result.atlasId}`
+ case "terms":
+ return `/terms?id=${result.atlasId}`
+ case "initiatives":
+ return `/initiatives?id=${result.atlasId}`
+ case "users":
+ return `/users?id=${result.atlasId}`
+ case "groups":
+ return `/groups?id=${result.atlasId}`
+ case "external":
+ return result.url ?? "#"
+ }
+}
+
+function getFacetValues(params: SearchPageParams, key: string): string[] {
+ const value = params[key]
+ if (typeof value === "string") return value ? [value] : []
+ if (Array.isArray(value)) return value.filter(Boolean)
+ return []
+}
+
+function renderResultMeta(result: SearchResultDto) {
+ if (result.type === "reports") {
+ return (
+
+ {result.reportType ? {result.reportType} : null}
+ {result.epicMasterFile ? (
+ Epic: {result.epicMasterFile}
+ ) : null}
+ {result.documented ? (
+ Documented: {result.documented}
+ ) : null}
+ {result.certifications.map((value) => (
+
+ {value}
+
+ ))}
+
+ )
+ }
+
+ if (result.type === "users") {
+ return result.email ? (
+ {result.email}
+ ) : null
+ }
+
+ if (result.type === "groups") {
+ return (
+
+ {result.email ? {result.email} : null}
+ {result.groupType ? {result.groupType} : null}
+
+ )
+ }
+
+ if (result.type === "external" && result.url) {
+ return (
+
+ )
+ }
+
+ return null
+}
+
+function renderHighlightSnippet(snippet: string) {
+ const parts = snippet.split(/(<\/?em>)/g)
+ let isEmphasized = false
+ let nodeIndex = 0
+ const nodes: ReactNode[] = []
+
+ parts.forEach((part) => {
+ if (part === "") {
+ isEmphasized = true
+ return
+ }
+ if (part === " ") {
+ isEmphasized = false
+ return
+ }
+
+ const sanitized = part.replace(/<[^>]+>/g, "")
+ if (!sanitized) return
+
+ if (isEmphasized) {
+ const key = `em-${nodeIndex}`
+ nodeIndex += 1
+ nodes.push(
+
+ {sanitized}
+ ,
+ )
+ return
+ }
+
+ const key = `txt-${nodeIndex}`
+ nodeIndex += 1
+ nodes.push({sanitized} )
+ })
+
+ return nodes
+}
+
+export default async function SearchPage({ searchParams }: { searchParams: SearchPageParams }) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const user = await getCurrentUser()
+ const canUseAdvancedSearch = !!user && hasPermission(user, "Show Advanced Search")
+
+ const q = getSingleValue(searchParams.q) ?? ""
+ const type = normalizeSearchType(getSingleValue(searchParams.type))
+ const page = asPositiveInt(getSingleValue(searchParams.page), 1)
+ const pageSize = asPositiveInt(getSingleValue(searchParams.pageSize), 20)
+ const field = getSingleValue(searchParams.field)
+ const advancedRequested = getSingleValue(searchParams.advanced) === "Y"
+
+ const requestParams: SearchPageParams = {
+ q,
+ type,
+ page: String(page),
+ pageSize: String(Math.min(100, pageSize)),
+ }
+
+ if (field) requestParams.field = field
+ if (canUseAdvancedSearch && advancedRequested) requestParams.advanced = "Y"
+
+ // Preserve all dynamic facet filters by passing every non-reserved param through.
+ Object.entries(searchParams).forEach(([key, value]) => {
+ if (!RESERVED_KEYS.has(key)) {
+ requestParams[key] = value
+ }
+ })
+
+ const result = await searchLibrary(requestParams)
+ if (!result.data) {
+ const message = getUserFriendlyErrorMessage(result.error ?? "unknown")
+ return (
+
+
+
+ Unable to load search
+
+
+ {message}
+
+ Back to home
+
+
+
+
+ )
+ }
+
+ const data = result.data
+ const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize))
+
+ const prevParams = new URLSearchParams()
+ Object.entries(requestParams).forEach(([key, value]) => {
+ if (typeof value === "string") {
+ prevParams.set(key, value)
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v) => {
+ prevParams.append(key, v)
+ })
+ }
+ })
+ prevParams.set("page", String(Math.max(1, data.page - 1)))
+
+ const nextParams = new URLSearchParams(prevParams.toString())
+ nextParams.set("page", String(Math.min(totalPages, data.page + 1)))
+
+ return (
+
+
+
+
+ Search
+
+
+
+
+
+
+ {data.facets.map((facet) => {
+ const selected = new Set(getFacetValues(searchParams, facet.key))
+ return (
+
+
+ {facet.key}
+
+
+ {facet.values.map((value) => {
+ const isActive = selected.has(value.value)
+ const params = new URLSearchParams()
+ Object.entries(requestParams).forEach(([key, requestValue]) => {
+ if (key === facet.key) {
+ return
+ }
+ if (typeof requestValue === "string") {
+ params.append(key, requestValue)
+ }
+ if (Array.isArray(requestValue)) {
+ requestValue.forEach((v) => {
+ params.append(key, v)
+ })
+ }
+ })
+
+ const nextValues = isActive
+ ? Array.from(selected).filter((item) => item !== value.value)
+ : [...Array.from(selected), value.value]
+
+ nextValues.forEach((v) => {
+ params.append(facet.key, v)
+ })
+ params.set("page", "1")
+
+ return (
+
+
+ {value.value}
+
+ {value.count}
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+
+
+
+ {data.total} result{data.total === 1 ? "" : "s"} • page {data.page} of {totalPages} •
+ qTime {data.qTime}
+ ms
+
+ {data.isAdvancedSearch ?
Advanced mode active : null}
+
+
+ {data.results.length === 0 ? (
+
+
+ No search results found.
+
+
+ ) : (
+ data.results.map((item) => {
+ const highlight = data.highlights.find((h) => h.id === item.id)
+ const descriptionSnippet = highlight?.fields.find(
+ (f) => f.field === "description",
+ )?.snippet
+ const href = buildResultHref(item)
+ const isExternal = item.type === "external"
+
+ return (
+
+
+
+
+ {isExternal ? (
+
+ {item.name}
+
+ ) : (
+
+ {item.name}
+
+ )}
+
+
+ {item.type}
+ {typeof item.isStarred === "boolean" ? (
+
+ {item.isStarred ? "Starred" : "Not starred"}
+
+ ) : null}
+
+
+
+
+ {descriptionSnippet ? (
+
+ {renderHighlightSnippet(descriptionSnippet)}
+
+ ) : item.description ? (
+ {item.description}
+ ) : null}
+ {renderResultMeta(item)}
+
+
+ )
+ })
+ )}
+
+ {data.total > data.pageSize ? (
+
+
+ Previous
+
+ = totalPages}>
+ Next
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/app/users/actions.ts b/frontend/app/users/actions.ts
new file mode 100644
index 000000000..844deb5ee
--- /dev/null
+++ b/frontend/app/users/actions.ts
@@ -0,0 +1,86 @@
+"use server"
+
+import {
+ createUserFolder,
+ deleteUserFolder,
+ removeUserSharedObject,
+ reorderUserFavorites,
+ reorderUserFolders,
+ toggleAdminMode,
+ toggleUserFavorite,
+ updateUserFavoriteFolderAssignment,
+ updateUserFolder,
+} from "@/lib/users/api"
+import type {
+ CreateUserFavoriteFolderRequest,
+ ReorderUserFavoriteFolderItem,
+ ReorderUserFavoriteItem,
+ ToggleUserFavoriteRequest,
+ UpdateUserFavoriteFolderAssignmentRequest,
+ UpdateUserFavoriteFolderRequest,
+} from "@/lib/users/types"
+
+export async function createUserFolderAction(
+ userId: number,
+ isCurrentUser: boolean,
+ body: CreateUserFavoriteFolderRequest,
+) {
+ return createUserFolder(userId, isCurrentUser, body)
+}
+
+export async function updateUserFolderAction(
+ userId: number,
+ isCurrentUser: boolean,
+ folderId: number,
+ body: UpdateUserFavoriteFolderRequest,
+) {
+ return updateUserFolder(userId, isCurrentUser, folderId, body)
+}
+
+export async function deleteUserFolderAction(
+ userId: number,
+ isCurrentUser: boolean,
+ folderId: number,
+) {
+ return deleteUserFolder(userId, isCurrentUser, folderId)
+}
+
+export async function reorderUserFoldersAction(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ReorderUserFavoriteFolderItem[],
+) {
+ return reorderUserFolders(userId, isCurrentUser, body)
+}
+
+export async function reorderUserFavoritesAction(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ReorderUserFavoriteItem[],
+) {
+ return reorderUserFavorites(userId, isCurrentUser, body)
+}
+
+export async function updateUserFavoriteFolderAssignmentAction(
+ userId: number,
+ isCurrentUser: boolean,
+ body: UpdateUserFavoriteFolderAssignmentRequest,
+) {
+ return updateUserFavoriteFolderAssignment(userId, isCurrentUser, body)
+}
+
+export async function toggleUserFavoriteAction(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ToggleUserFavoriteRequest,
+) {
+ return toggleUserFavorite(userId, isCurrentUser, body)
+}
+
+export async function removeUserSharedObjectAction(id: number) {
+ return removeUserSharedObject(id)
+}
+
+export async function toggleAdminModeAction() {
+ return toggleAdminMode()
+}
diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx
new file mode 100644
index 000000000..bf721956c
--- /dev/null
+++ b/frontend/app/users/page.tsx
@@ -0,0 +1,172 @@
+import Link from "next/link"
+import { redirect } from "next/navigation"
+import { LibraryShell } from "@/components/layout/library-shell"
+import { ProfileFullView } from "@/components/profile/profile-full-view"
+import { Button } from "@/components/ui/button"
+import { UserGroupsTable } from "@/components/users/user-groups-table"
+import { UserHistorySectionView } from "@/components/users/user-history-section"
+import { UserPageTabs } from "@/components/users/user-page-tabs"
+import { UserRunListPanel } from "@/components/users/user-run-list-panel"
+import { UserStarsWorkspace } from "@/components/users/user-stars-workspace"
+import { UserSubscriptionsTable } from "@/components/users/user-subscriptions-table"
+import { getCurrentUser, getToken } from "@/lib/auth"
+import { getUserFriendlyErrorMessage } from "@/lib/errors"
+import {
+ getUserGroups,
+ getUserHistory,
+ getUserPage,
+ getUserStars,
+ getUserSubscriptions,
+} from "@/lib/users/api"
+
+type UsersSearchParams = {
+ id?: string
+}
+
+function getSingleValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value[0]
+ return undefined
+}
+
+function getShellDisplayName(
+ user:
+ | {
+ fullname?: string | null
+ username?: string | null
+ }
+ | null,
+) {
+ return user?.fullname?.trim() || user?.username?.trim() || "Guest"
+}
+
+export default async function UsersPage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ const token = await getToken()
+ if (!token) redirect("/auth/login")
+
+ const currentUser = await getCurrentUser()
+ const resolvedSearchParams = await searchParams
+ const idRaw = getSingleValue(resolvedSearchParams.id)
+ const resolvedId = idRaw ? Number(idRaw) : Number(currentUser?.userId)
+ if (!Number.isFinite(resolvedId) || resolvedId <= 0) {
+ redirect("/")
+ }
+
+ if (!idRaw && currentUser?.userId) {
+ redirect(`/users?id=${currentUser.userId}`)
+ }
+
+ const [pageResult, starsResult, groupsResult, subscriptionsResult, historyResult] =
+ await Promise.all([
+ getUserPage(resolvedId),
+ getUserStars(resolvedId),
+ getUserGroups(resolvedId),
+ getUserSubscriptions(resolvedId),
+ getUserHistory(resolvedId),
+ ])
+
+ const page = pageResult.data
+ if (!page) {
+ const message = getUserFriendlyErrorMessage(pageResult.error ?? "unknown")
+ const denied = pageResult.error === "forbidden" || pageResult.status === 403
+
+ return (
+
+
+ {denied ? "Access denied" : "Unable to load user profile"}
+
+
+ {denied ? "You do not have access to this page." : message}
+
+
+ Back to home
+
+
+ )
+ }
+
+ const stars = starsResult.data
+ const groups = groupsResult.data ?? []
+ const subscriptions = subscriptionsResult.data ?? []
+ const history = historyResult.data ?? {
+ atlasHistory: [],
+ reportEdits: [],
+ initiativeEdits: [],
+ collectionEdits: [],
+ termEdits: [],
+ }
+
+ const title = page.viewer.isCurrentUser
+ ? `Hi, ${page.user.firstName?.trim() || page.user.displayName?.trim() || page.user.fullName?.trim() || "there"}`
+ : `You are viewing ${page.user.fullName?.trim() || page.user.displayName?.trim() || page.user.username?.trim() || "this user"}'s Profile`
+ const shellDisplayName =
+ currentUser?.fullname?.trim() ||
+ page.user.displayName?.trim() ||
+ page.user.fullName?.trim() ||
+ page.user.username?.trim() ||
+ "Guest"
+
+ return (
+
+
+
+
+ ) : (
+
+ Unable to load stars workspace.
+
+ )
+ }
+ subscriptions={ }
+ groups={ }
+ activity={
+
+ }
+ runList={ }
+ atlasHistory={ }
+ analytics={
+ page.permissions.canViewAnalytics ? (
+
+
+ Site analytics for this user are available in the Analytics application.
+
+
+ Open Analytics
+
+
+ ) : (
+
+ Analytics are not available.
+
+ )
+ }
+ />
+
+ )
+}
diff --git a/frontend/biome.json b/frontend/biome.json
new file mode 100644
index 000000000..0551cdb30
--- /dev/null
+++ b/frontend/biome.json
@@ -0,0 +1,63 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
+ "root": true,
+ "files": {
+ "ignoreUnknown": true,
+ "includes": ["**/*", "!**/.next", "!**/node_modules", "!app/globals.css"]
+ },
+ "vcs": {
+ "enabled": false
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "suspicious": {
+ "noConsole": {
+ "level": "error",
+ "options": {
+ "allow": ["error", "warn"]
+ }
+ }
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "trailingCommas": "all",
+ "semicolons": "asNeeded"
+ }
+ },
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
+ },
+ "overrides": [
+ {
+ "includes": ["app/globals.css"],
+ "css": {
+ "formatter": {
+ "enabled": false
+ },
+ "linter": {
+ "enabled": false
+ }
+ }
+ }
+ ]
+}
diff --git a/frontend/commitlint.config.js b/frontend/commitlint.config.js
new file mode 100644
index 000000000..3af324fb4
--- /dev/null
+++ b/frontend/commitlint.config.js
@@ -0,0 +1,3 @@
+export default {
+ extends: ["@commitlint/config-conventional"],
+}
diff --git a/frontend/components.json b/frontend/components.json
new file mode 100644
index 000000000..02e61e070
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "radix-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/frontend/components/auth/auth-provider.tsx b/frontend/components/auth/auth-provider.tsx
new file mode 100644
index 000000000..42dd49abc
--- /dev/null
+++ b/frontend/components/auth/auth-provider.tsx
@@ -0,0 +1,131 @@
+"use client"
+
+import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
+import type { AuthUser, Permission } from "@/lib/auth/types"
+
+// ---------------------------------------------------------------------------
+// Context shape
+// ---------------------------------------------------------------------------
+interface AuthContextValue {
+ /** The authenticated user, or `null` while loading / unauthenticated. */
+ user: AuthUser | null
+ /** `true` while the initial /api/auth/me request is in-flight. */
+ loading: boolean
+ /** Re-fetch the user from the API (e.g. after toggling admin mode). */
+ refresh: () => Promise
+
+ // ── Permission helpers ──────────────────────────────────────────────
+ /** `true` when adminEnabled is `true`. */
+ isAdmin: boolean
+ /** Check a single permission. Admin bypasses all checks. */
+ hasPermission: (permission: Permission) => boolean
+ /** Check that the user holds ALL of the given permissions. */
+ hasAllPermissions: (permissions: Permission[]) => boolean
+ /** Check that the user holds at least ONE of the given permissions. */
+ hasAnyPermission: (permissions: Permission[]) => boolean
+ /** Check that the user has a specific role. */
+ hasRole: (role: string) => boolean
+ /** Check that the user has at least ONE of the given roles. */
+ hasAnyRole: (roles: string[]) => boolean
+}
+
+const AuthContext = createContext(undefined)
+
+// ---------------------------------------------------------------------------
+// Provider
+// ---------------------------------------------------------------------------
+interface AuthProviderProps {
+ children: ReactNode
+ /**
+ * Optionally pass the user fetched on the server so the client can hydrate
+ * immediately without a loading flash.
+ */
+ initialUser?: AuthUser | null
+}
+
+export function AuthProvider({ children, initialUser }: AuthProviderProps) {
+ const [user, setUser] = useState(initialUser ?? null)
+ const [loading, setLoading] = useState(!initialUser)
+
+ // ── Fetch user from the API ──────────────────────────────────────────
+ const fetchUser = useCallback(async () => {
+ try {
+ setLoading(true)
+ const res = await fetch("/api/auth/me")
+ if (!res.ok) {
+ setUser(null)
+ return
+ }
+ const data: AuthUser = await res.json()
+ setUser(data)
+ } catch {
+ setUser(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ // If we already have a server-supplied user, skip the client fetch.
+ if (initialUser) return
+ fetchUser()
+ }, [initialUser, fetchUser])
+
+ // ── Permission helpers ──────────────────────────────────────────────
+ const isAdmin = user?.adminEnabled ?? false
+
+ const hasPermission = useCallback(
+ (permission: Permission) => {
+ if (!user) return false
+ if (user.adminEnabled) return true
+ return user.permissions.includes(permission)
+ },
+ [user],
+ )
+
+ const hasAllPermissions = useCallback(
+ (permissions: Permission[]) => permissions.every((p) => hasPermission(p)),
+ [hasPermission],
+ )
+
+ const hasAnyPermission = useCallback(
+ (permissions: Permission[]) => permissions.some((p) => hasPermission(p)),
+ [hasPermission],
+ )
+
+ const hasRole = useCallback((role: string) => user?.roles.includes(role) ?? false, [user])
+
+ const hasAnyRole = useCallback(
+ (roles: string[]) => roles.some((r) => user?.roles.includes(r) ?? false),
+ [user],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext)
+ if (!ctx) {
+ throw new Error("useAuth() must be used within an ")
+ }
+ return ctx
+}
diff --git a/frontend/components/auth/index.ts b/frontend/components/auth/index.ts
new file mode 100644
index 000000000..822da93c3
--- /dev/null
+++ b/frontend/components/auth/index.ts
@@ -0,0 +1,6 @@
+export { AuthProvider, useAuth } from "./auth-provider"
+export {
+ RequireAdmin,
+ RequirePermission,
+ RequireRole,
+} from "./require-permission"
diff --git a/frontend/components/auth/require-permission.tsx b/frontend/components/auth/require-permission.tsx
new file mode 100644
index 000000000..597709cbe
--- /dev/null
+++ b/frontend/components/auth/require-permission.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import type { ReactNode } from "react"
+import type { Permission } from "@/lib/auth/types"
+import { useAuth } from "./auth-provider"
+
+// ---------------------------------------------------------------------------
+// Declarative permission gate component
+// ---------------------------------------------------------------------------
+
+interface RequirePermissionProps {
+ /** The permission(s) the user must have. */
+ permission: Permission | Permission[]
+ /**
+ * When multiple permissions are provided:
+ * - `"all"` → user needs ALL of them (default)
+ * - `"any"` → user needs at least ONE
+ */
+ mode?: "all" | "any"
+ /** Content to render when the user lacks the permission. */
+ fallback?: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Conditionally renders children based on the current user's permissions.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function RequirePermission({
+ permission,
+ mode = "all",
+ fallback = null,
+ children,
+}: RequirePermissionProps) {
+ const { hasPermission, hasAllPermissions, hasAnyPermission, loading } = useAuth()
+
+ if (loading) return null
+
+ const allowed = Array.isArray(permission)
+ ? mode === "any"
+ ? hasAnyPermission(permission)
+ : hasAllPermissions(permission)
+ : hasPermission(permission)
+
+ return allowed ? children : fallback
+}
+
+// ---------------------------------------------------------------------------
+// Role-based gate
+// ---------------------------------------------------------------------------
+
+interface RequireRoleProps {
+ /** The role(s) the user must have. */
+ role: string | string[]
+ /** When multiple roles: `"any"` = at least one (default), `"all"` = every role. */
+ mode?: "all" | "any"
+ fallback?: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Conditionally renders children based on the current user's roles.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export function RequireRole({ role, mode = "any", fallback = null, children }: RequireRoleProps) {
+ const { hasRole, hasAnyRole, loading } = useAuth()
+
+ if (loading) return null
+
+ const allowed = Array.isArray(role)
+ ? mode === "any"
+ ? hasAnyRole(role)
+ : role.every((r) => hasRole(r))
+ : hasRole(role)
+
+ return allowed ? children : fallback
+}
+
+// ---------------------------------------------------------------------------
+// Admin-only gate
+// ---------------------------------------------------------------------------
+
+interface RequireAdminProps {
+ fallback?: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Renders children only when `adminEnabled` is `true`.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export function RequireAdmin({ fallback = null, children }: RequireAdminProps) {
+ const { isAdmin, loading } = useAuth()
+
+ if (loading) return null
+
+ return isAdmin ? children : fallback
+}
diff --git a/frontend/components/collections/collection-action-rail.tsx b/frontend/components/collections/collection-action-rail.tsx
new file mode 100644
index 000000000..8aeb91907
--- /dev/null
+++ b/frontend/components/collections/collection-action-rail.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import { Pencil, Plus, Trash2 } from "lucide-react"
+import { useRouter } from "next/navigation"
+import type { ReactNode } from "react"
+import { useTransition } from "react"
+import { deleteCollectionAction } from "@/app/collections/actions"
+import {
+ ActionRail,
+ ActionRailGroup,
+ RailIconLink,
+ RailTooltipButton,
+} from "@/components/interactions/action-rail"
+import { EntityEngagementRailActions } from "@/components/interactions/entity-engagement-rail-actions"
+import type { CollectionDetailDto } from "@/lib/collections/types"
+
+function RailDeleteButton({
+ collectionId,
+ collectionName,
+}: {
+ collectionId: number
+ collectionName: string
+}) {
+ const router = useRouter()
+ const [pending, startTransition] = useTransition()
+
+ return (
+ {
+ const label = collectionName.trim() || `collection ${collectionId}`
+ if (!window.confirm(`Delete ${label}? This cannot be undone.`)) return
+ startTransition(() => {
+ void (async () => {
+ const result = await deleteCollectionAction(collectionId)
+ if (!result?.error) router.refresh()
+ })()
+ })
+ }}
+ >
+
+ Delete collection
+
+ )
+}
+
+export function CollectionActionRail({
+ collection,
+ profilePanel,
+}: {
+ collection: CollectionDetailDto
+ profilePanel: ReactNode
+}) {
+ const features = collection.features ?? {}
+ const shareUrl = `/collections?id=${collection.id}`
+ const hasAdminActions =
+ collection.canCreateCollection || collection.canEditCollection || collection.canDeleteCollection
+
+ return (
+
+
+
+
+
+ {hasAdminActions ? (
+
+ {collection.canCreateCollection ? (
+
+
+ Create new collection
+
+ ) : null}
+
+ {collection.canEditCollection ? (
+
+
+ Edit collection
+
+ ) : null}
+
+ {collection.canDeleteCollection ? (
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/components/collections/collection-detail-section.tsx b/frontend/components/collections/collection-detail-section.tsx
new file mode 100644
index 000000000..50b25427d
--- /dev/null
+++ b/frontend/components/collections/collection-detail-section.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react"
+import { cn } from "@/lib/utils"
+
+export function CollectionDetailSection({
+ id,
+ title,
+ children,
+ className,
+}: {
+ id: string
+ title: string
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+ )
+}
+
+export function CollectionSubsection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
diff --git a/frontend/components/collections/collection-feedback-dialog.tsx b/frontend/components/collections/collection-feedback-dialog.tsx
new file mode 100644
index 000000000..d83d672f4
--- /dev/null
+++ b/frontend/components/collections/collection-feedback-dialog.tsx
@@ -0,0 +1,14 @@
+"use client"
+
+import { EntityFeedbackDialog } from "@/components/interactions/entity-feedback-dialog"
+
+/** @deprecated Use EntityFeedbackDialog from components/interactions */
+export function CollectionFeedbackDialog({
+ collectionName,
+ collectionUrl,
+}: {
+ collectionName: string
+ collectionUrl: string
+}) {
+ return
+}
diff --git a/frontend/components/collections/collection-form.tsx b/frontend/components/collections/collection-form.tsx
new file mode 100644
index 000000000..f85ef9a7d
--- /dev/null
+++ b/frontend/components/collections/collection-form.tsx
@@ -0,0 +1,490 @@
+"use client"
+
+import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, X } from "lucide-react"
+import Link from "next/link"
+import type { Dispatch, SetStateAction } from "react"
+import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
+import {
+ createCollectionAction,
+ searchCollectionReportsAction,
+ searchCollectionTermsAction,
+ updateCollectionAction,
+} from "@/app/collections/actions"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Switch } from "@/components/ui/switch"
+import type { CollectionDetailDto, CollectionTermDto } from "@/lib/collections/types"
+
+type PickerRow = { id: number; label: string; subtitle?: string | null }
+
+function termIdFromDto(term: CollectionTermDto): number | null {
+ if (typeof term.id === "number") return term.id
+ if (typeof term.termId === "number") return term.termId
+ return null
+}
+
+function sortByRank(items: T[]): T[] {
+ return [...items].sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
+}
+
+function useDebouncedTypeahead(
+ query: string,
+ fetcher: (q: string) => Promise<{ id: number; name: string; description?: string | null }[]>,
+) {
+ const [results, setResults] = useState<
+ { id: number; name: string; description?: string | null }[]
+ >([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (!trimmed) {
+ setResults([])
+ setLoading(false)
+ return
+ }
+
+ setLoading(true)
+ const handle = window.setTimeout(() => {
+ void fetcher(trimmed)
+ .then(setResults)
+ .catch(() => {
+ setResults([])
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }, 280)
+
+ return () => {
+ window.clearTimeout(handle)
+ }
+ }, [query, fetcher])
+
+ return { results, loading }
+}
+
+function LinkedItemTags({
+ rows,
+ onRemove,
+ onMove,
+}: {
+ rows: PickerRow[]
+ onRemove: (id: number) => void
+ onMove: (id: number, direction: "up" | "down") => void
+}) {
+ if (rows.length === 0) {
+ return None selected.
+ }
+
+ return (
+
+ )
+}
+
+const textareaClassName =
+ "w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
+
+export function CollectionForm({
+ mode,
+ collectionId,
+ initial,
+ cancelHref,
+}: {
+ mode: "create" | "edit"
+ collectionId?: number
+ initial?: CollectionDetailDto | null
+ cancelHref: string
+}) {
+ const [name, setName] = useState(initial?.name ?? "")
+ const [description, setDescription] = useState(initial?.description ?? "")
+ const [purpose, setPurpose] = useState(initial?.purpose ?? "")
+ const [hidden, setHidden] = useState((initial?.hidden ?? "N").toUpperCase() === "Y")
+
+ const initialTermRows = useMemo((): PickerRow[] => {
+ const terms = initial?.terms ? sortByRank(initial.terms) : []
+ const rows: PickerRow[] = []
+ for (const term of terms) {
+ const id = termIdFromDto(term)
+ if (id == null) continue
+ rows.push({
+ id,
+ label: term.name?.trim() || `Term ${id}`,
+ subtitle: term.summary ?? null,
+ })
+ }
+ return rows
+ }, [initial?.terms])
+
+ const initialReportRows = useMemo(() => {
+ const reports = initial?.reports ? sortByRank(initial.reports) : []
+ return reports.map((report) => ({
+ id: report.id,
+ label: report.name?.trim() || `Report ${report.id}`,
+ subtitle: report.description ?? null,
+ }))
+ }, [initial?.reports])
+
+ const [termRows, setTermRows] = useState(initialTermRows)
+ const [reportRows, setReportRows] = useState(initialReportRows)
+
+ const [termQuery, setTermQuery] = useState("")
+ const [reportQuery, setReportQuery] = useState("")
+
+ const termFetcher = useCallback((q: string) => searchCollectionTermsAction(q), [])
+ const reportFetcher = useCallback((q: string) => searchCollectionReportsAction(q), [])
+
+ const termTypeahead = useDebouncedTypeahead(termQuery, termFetcher)
+ const reportTypeahead = useDebouncedTypeahead(reportQuery, reportFetcher)
+
+ const [formError, setFormError] = useState(null)
+ const [isPending, startTransition] = useTransition()
+
+ const pageTitle =
+ mode === "create" ? "Create a Collection" : `Editing ${initial?.name?.trim() || "collection"}`
+
+ const moveRow = (
+ setter: Dispatch>,
+ id: number,
+ direction: "up" | "down",
+ ) => {
+ setter((rows) => {
+ const index = rows.findIndex((row) => row.id === id)
+ if (index < 0) return rows
+ const target = direction === "up" ? index - 1 : index + 1
+ if (target < 0 || target >= rows.length) return rows
+ const next = [...rows]
+ const temp = next[index]
+ const swap = next[target]
+ if (!temp || !swap) return rows
+ next[index] = swap
+ next[target] = temp
+ return next
+ })
+ }
+
+ const submit = () => {
+ setFormError(null)
+ const trimmedName = name.trim()
+ if (!trimmedName) {
+ setFormError("Name is required.")
+ return
+ }
+
+ const body = {
+ name: trimmedName,
+ description: description.trim() ? description.trim() : null,
+ purpose: purpose.trim() ? purpose.trim() : null,
+ hidden: hidden ? "Y" : "N",
+ termIds: termRows.map((row) => row.id),
+ reportIds: reportRows.map((row) => row.id),
+ }
+
+ startTransition(() => {
+ void (async () => {
+ if (mode === "create") {
+ const result = await createCollectionAction(body)
+ if (result?.error) {
+ setFormError(result.error)
+ }
+ return
+ }
+
+ if (typeof collectionId !== "number") {
+ setFormError("Missing collection id.")
+ return
+ }
+
+ const result = await updateCollectionAction(collectionId, body)
+ if (result?.error) {
+ setFormError(result.error)
+ }
+ })()
+ })
+ }
+
+ return (
+
+
{pageTitle}
+
+
+
+
+
+
+ Cancel
+ Go back
+
+
+
+
+
+ {isPending ? "Saving…" : "Save"}
+ and continue
+
+
+
+
+
+
+
+ Basics
+
+
+
+ Name
+ {
+ setName(event.target.value)
+ }}
+ required
+ />
+
+
+ {
+ setHidden(checked)
+ }}
+ />
+
+ Hide collection from search?
+
+
+
+
+
+
+
+ Content
+
+
+
+
Purpose
+
+ Supports Markdown. Shown as search summary.
+
+
+
+
Description
+
+ Supports Markdown. Shown on the collection page.
+
+
+
+
+
+
+
+
+ Linked Terms
+
+
+
+
Search terms
+
{
+ setTermQuery(event.target.value)
+ }}
+ placeholder="Search for terms…"
+ autoComplete="off"
+ />
+ {termTypeahead.loading ? (
+
Searching…
+ ) : null}
+ {termQuery.trim() && termTypeahead.results.length > 0 ? (
+
+ {termTypeahead.results.map((item) => (
+
+ {
+ setTermRows((rows) => {
+ if (rows.some((row) => row.id === item.id)) return rows
+ return [
+ ...rows,
+ {
+ id: item.id,
+ label: item.name,
+ subtitle: item.description ?? null,
+ },
+ ]
+ })
+ setTermQuery("")
+ }}
+ >
+ {item.name}
+ {item.description ? (
+ {item.description}
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+
+ {
+ setTermRows((rows) => rows.filter((row) => row.id !== id))
+ }}
+ onMove={(id, direction) => {
+ moveRow(setTermRows, id, direction)
+ }}
+ />
+
+
+
+
+
+ Linked Reports
+
+
+
+
Search reports
+
{
+ setReportQuery(event.target.value)
+ }}
+ placeholder="Search for reports…"
+ autoComplete="off"
+ />
+ {reportTypeahead.loading ? (
+
Searching…
+ ) : null}
+ {reportQuery.trim() && reportTypeahead.results.length > 0 ? (
+
+ {reportTypeahead.results.map((item) => (
+
+ {
+ setReportRows((rows) => {
+ if (rows.some((row) => row.id === item.id)) return rows
+ return [
+ ...rows,
+ {
+ id: item.id,
+ label: item.name,
+ subtitle: item.description ?? null,
+ },
+ ]
+ })
+ setReportQuery("")
+ }}
+ >
+ {item.name}
+ {item.description ? (
+ {item.description}
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+
+ {
+ setReportRows((rows) => rows.filter((row) => row.id !== id))
+ }}
+ onMove={(id, direction) => {
+ moveRow(setReportRows, id, direction)
+ }}
+ />
+
+ Order defines report rank on the collection page.
+
+
+
+
+
+ {formError ?
{formError}
: null}
+
+ )
+}
diff --git a/frontend/components/collections/collection-metadata-table.tsx b/frontend/components/collections/collection-metadata-table.tsx
new file mode 100644
index 000000000..b237dc43a
--- /dev/null
+++ b/frontend/components/collections/collection-metadata-table.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react"
+import type { CollectionDetailDto } from "@/lib/collections/types"
+
+export function CollectionMetadataTable({ collection }: { collection: CollectionDetailDto }) {
+ const rows: Array<{ label: string; value: ReactNode }> = []
+
+ const updatedBy = collection.lastUpdatedBy?.fullName?.trim() || collection.lastUpdatedBy?.username
+ if (updatedBy) {
+ rows.push({ label: "Last Updated By", value: updatedBy })
+ }
+ if (collection.lastModifiedDisplay) {
+ rows.push({ label: "Last Updated", value: collection.lastModifiedDisplay })
+ }
+ if (collection.hidden === "Y") {
+ rows.push({ label: "Hidden from Search?", value: "Yes" })
+ }
+
+ if (rows.length === 0) return null
+
+ return (
+
+
+
+ {rows.map((row, index) => (
+
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/collections/collection-profile-sheet.tsx b/frontend/components/collections/collection-profile-sheet.tsx
new file mode 100644
index 000000000..bddf34a58
--- /dev/null
+++ b/frontend/components/collections/collection-profile-sheet.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { EntityProfileSheet } from "@/components/interactions/entity-profile-sheet"
+
+/** @deprecated Use EntityProfileSheet from components/interactions */
+export function CollectionProfileSheet({
+ collectionName,
+ children,
+ variant = "default",
+}: {
+ collectionName: string
+ children: ReactNode
+ variant?: "default" | "footer"
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/components/collections/collection-section-nav.tsx b/frontend/components/collections/collection-section-nav.tsx
new file mode 100644
index 000000000..0eaeed371
--- /dev/null
+++ b/frontend/components/collections/collection-section-nav.tsx
@@ -0,0 +1,36 @@
+import { ChevronRight } from "lucide-react"
+
+/** In-page section links matching Razor `Collections/_Links.cshtml`. */
+export function CollectionSectionNav({
+ hasReports,
+ hasTerms,
+}: {
+ hasReports: boolean
+ hasTerms: boolean
+}) {
+ const links = [
+ { href: "#details", label: "Details" },
+ ...(hasReports ? [{ href: "#reports", label: "Linked Reports" }] : []),
+ ...(hasTerms ? [{ href: "#terms", label: "Linked Terms" }] : []),
+ ]
+
+ return (
+
+
+ {links.map((link, index) => (
+
+ {index > 0 ? (
+
+ ) : null}
+
+ {link.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/components/collections/collections-list-card.tsx b/frontend/components/collections/collections-list-card.tsx
new file mode 100644
index 000000000..afdcf6b55
--- /dev/null
+++ b/frontend/components/collections/collections-list-card.tsx
@@ -0,0 +1,66 @@
+import { BadgeCheck } from "lucide-react"
+import Link from "next/link"
+import { EntityCardFooter } from "@/components/interactions/entity-card-footer"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import type { CollectionListItemDto } from "@/lib/collections/types"
+import { truncateText } from "@/lib/text"
+
+export function CollectionsListCard({ collection }: { collection: CollectionListItemDto }) {
+ const href = `/collections?id=${collection.id}`
+ const excerpt = collection.description?.trim() ? truncateText(collection.description) : null
+
+ return (
+
+
+
+ {collection.name}
+
+
+
+
+
+ collection
+
+
+
+
+
+ {/* biome-ignore lint/performance/noImgElement: proxied backend placeholder matches C# _Snippet.cshtml */}
+
+
+
+
+ {excerpt ? (
+ <>
+ {excerpt} read more
+ >
+ ) : (
+ Open to view details.
+ )}
+
+
+
+
+ }
+ />
+
+ )
+}
diff --git a/frontend/components/collections/delete-collection-button.tsx b/frontend/components/collections/delete-collection-button.tsx
new file mode 100644
index 000000000..ce5e712d0
--- /dev/null
+++ b/frontend/components/collections/delete-collection-button.tsx
@@ -0,0 +1,42 @@
+"use client"
+
+import { useState, useTransition } from "react"
+import { deleteCollectionAction } from "@/app/collections/actions"
+import { Button } from "@/components/ui/button"
+
+export function DeleteCollectionButton({
+ collectionId,
+ collectionName,
+}: {
+ collectionId: number
+ collectionName: string
+}) {
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ return (
+
+
{
+ const label = collectionName.trim() || `collection ${collectionId}`
+ if (!window.confirm(`Delete ${label}? This cannot be undone.`)) return
+ setError(null)
+ startTransition(() => {
+ void (async () => {
+ const result = await deleteCollectionAction(collectionId)
+ if (result?.error) {
+ setError(result.error)
+ }
+ })()
+ })
+ }}
+ >
+ {pending ? "Deleting…" : "Delete collection"}
+
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/frontend/components/content/markdown-content.tsx b/frontend/components/content/markdown-content.tsx
new file mode 100644
index 000000000..174ca93d4
--- /dev/null
+++ b/frontend/components/content/markdown-content.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react"
+import ReactMarkdown from "react-markdown"
+import remarkGfm from "remark-gfm"
+import { cn } from "@/lib/utils"
+
+const markdownComponents = {
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ p: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ ul: ({ children }: { children?: ReactNode }) => (
+
+ ),
+ ol: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ h1: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ h2: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ h3: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ code: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ pre: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+}
+
+export function MarkdownContent({ content, className }: { content: string; className?: string }) {
+ return (
+
+
+ {content}
+
+
+ )
+}
diff --git a/frontend/components/groups/group-details-table.tsx b/frontend/components/groups/group-details-table.tsx
new file mode 100644
index 000000000..6b1061c6d
--- /dev/null
+++ b/frontend/components/groups/group-details-table.tsx
@@ -0,0 +1,36 @@
+import type { GroupDetail } from "@/lib/groups/types"
+
+export function GroupDetailsTable({ group }: { group: GroupDetail }) {
+ const rows: Array<{ label: string; value: string }> = []
+
+ if (group.email?.trim()) {
+ rows.push({ label: "Email", value: group.email.trim() })
+ }
+ rows.push({ label: "Type", value: group.type?.trim() || "—" })
+ rows.push({ label: "Source", value: group.source?.trim() || "—" })
+
+ return (
+
+
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+
+ {row.label === "Email" && row.value.includes("@") ? (
+
+ {row.value}
+
+ ) : (
+ row.value
+ )}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/groups/group-page-tabs.tsx b/frontend/components/groups/group-page-tabs.tsx
new file mode 100644
index 000000000..02428713a
--- /dev/null
+++ b/frontend/components/groups/group-page-tabs.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { useState } from "react"
+import {
+ GroupSectionNav,
+ type GroupTabId,
+ GroupTabPanel,
+} from "@/components/groups/group-section-nav"
+
+export function GroupPageTabs({
+ showAnalytics,
+ details,
+ users,
+ reports,
+ activity,
+ runList,
+ analytics,
+}: {
+ showAnalytics: boolean
+ details: ReactNode
+ users: ReactNode
+ reports: ReactNode
+ activity: ReactNode
+ runList: ReactNode
+ analytics: ReactNode
+}) {
+ const [activeTab, setActiveTab] = useState("details")
+
+ return (
+
+
+
+
+ {details}
+
+
+ {users}
+
+
+ {reports}
+
+
+ {activity}
+
+
+ {runList}
+
+ {showAnalytics ? (
+
+ {analytics}
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/components/groups/group-reports-table.tsx b/frontend/components/groups/group-reports-table.tsx
new file mode 100644
index 000000000..694185ef0
--- /dev/null
+++ b/frontend/components/groups/group-reports-table.tsx
@@ -0,0 +1,58 @@
+import Link from "next/link"
+import type { GroupReport } from "@/lib/groups/types"
+
+export function GroupReportsTable({ reports }: { reports: GroupReport[] }) {
+ if (reports.length === 0) {
+ return No Reports With This Group
+ }
+
+ return (
+
+
Reports With This Group
+
+
+
+
+
+ Name
+
+
+ Last Updated
+
+
+ # Subscriptions
+
+
+ # Favorites
+
+
+ # Runs (2yrs)
+
+
+
+
+ {reports.map((report) => (
+
+
+
+ {report.name?.trim() || `Report ${report.id}`}
+
+
+ {report.lastUpdated?.trim() || "—"}
+ {report.subscriptionCount ?? 0}
+ {report.favoriteCount ?? 0}
+ {report.runCount ?? 0}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/frontend/components/groups/group-run-list-panel.tsx b/frontend/components/groups/group-run-list-panel.tsx
new file mode 100644
index 000000000..1a51e480d
--- /dev/null
+++ b/frontend/components/groups/group-run-list-panel.tsx
@@ -0,0 +1,68 @@
+import Link from "next/link"
+import { getProfileRunList } from "@/lib/profile/api"
+import type { ProfileFilters } from "@/lib/profile/types"
+
+export async function GroupRunListPanel({
+ groupId,
+ reportTypeIds,
+}: {
+ groupId: number
+ reportTypeIds: number[]
+}) {
+ const filters: ProfileFilters = {
+ id: groupId,
+ type: "group",
+ reportType: reportTypeIds.length > 0 ? reportTypeIds : undefined,
+ }
+
+ const result = await getProfileRunList(filters)
+ const rows = result.data ?? []
+
+ if (rows.length === 0) {
+ return No report runs to show.
+ }
+
+ return (
+
+
+
+
+
+ Report
+
+
+ Type
+
+
+ Runs
+
+
+ Last Run
+
+
+
+
+ {rows.map((row) => (
+
+
+ {row.url ? (
+
+ {row.name}
+
+ ) : (
+ row.name
+ )}
+
+ {row.type?.trim() || "—"}
+ {typeof row.runs === "number" ? row.runs : "—"}
+ {row.lastRun?.trim() || "—"}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/groups/group-section-nav.tsx b/frontend/components/groups/group-section-nav.tsx
new file mode 100644
index 000000000..fd39c4026
--- /dev/null
+++ b/frontend/components/groups/group-section-nav.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { cn } from "@/lib/utils"
+
+export type GroupTabId = "details" | "users" | "reports" | "activity" | "run-list" | "analytics"
+
+const TAB_LABELS: Record = {
+ details: "Details",
+ users: "Users In Group",
+ reports: "Reports In Group",
+ activity: "Run Analytics",
+ "run-list": "Report Runs",
+ analytics: "Analytics",
+}
+
+export function GroupSectionNav({
+ activeTab,
+ onTabChange,
+ showAnalytics,
+}: {
+ activeTab: GroupTabId
+ onTabChange: (tab: GroupTabId) => void
+ showAnalytics: boolean
+}) {
+ const tabs: GroupTabId[] = [
+ "details",
+ "users",
+ "reports",
+ "activity",
+ "run-list",
+ ...(showAnalytics ? (["analytics"] as GroupTabId[]) : []),
+ ]
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ onTabChange(tab)}
+ className={cn(
+ "rounded-t-md px-3 py-2 font-medium transition-colors",
+ activeTab === tab
+ ? "border border-b-0 border-border/60 bg-background text-foreground"
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+ )}
+ aria-current={activeTab === tab ? "page" : undefined}
+ >
+ {TAB_LABELS[tab]}
+
+
+ ))}
+
+
+ )
+}
+
+export function GroupTabPanel({
+ tab,
+ activeTab,
+ children,
+}: {
+ tab: GroupTabId
+ activeTab: GroupTabId
+ children: ReactNode
+}) {
+ if (tab !== activeTab) return null
+ return
+}
diff --git a/frontend/components/groups/group-users-table.tsx b/frontend/components/groups/group-users-table.tsx
new file mode 100644
index 000000000..c2a05a8ac
--- /dev/null
+++ b/frontend/components/groups/group-users-table.tsx
@@ -0,0 +1,65 @@
+import Link from "next/link"
+import type { GroupUser } from "@/lib/groups/types"
+
+export function GroupUsersTable({ users }: { users: GroupUser[] }) {
+ if (users.length === 0) {
+ return No Users With This Group
+ }
+
+ return (
+
+
Users With This Group
+
+
+
+
+
+ Name
+
+
+ Email
+
+
+ Phone
+
+
+ Epic Id
+
+
+ Employee Id
+
+
+
+
+ {users.map((user) => (
+
+
+ {user.canOpenUserProfile && user.url ? (
+
+ {user.name?.trim() || `User ${user.id}`}
+
+ ) : (
+ user.name?.trim() || `User ${user.id}`
+ )}
+
+
+ {user.email?.trim() ? (
+
+ {user.email.trim()}
+
+ ) : null}
+
+ {user.phone?.trim() || "—"}
+ {user.epicId?.trim() || "—"}
+ {user.employeeId?.trim() || "—"}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-footer.tsx b/frontend/components/home/home-footer.tsx
new file mode 100644
index 000000000..1a58b1ac8
--- /dev/null
+++ b/frontend/components/home/home-footer.tsx
@@ -0,0 +1,66 @@
+import Image from "next/image"
+import Link from "next/link"
+
+const footerSections = [
+ {
+ title: "Status",
+ links: [
+ { label: "Documentation", href: "https://atlas.bi" },
+ { label: "Source Code", href: "https://github.com/atlas-bi/atlas-bi-library" },
+ { label: "Status", href: "https://status.atlas.bi/status/atlas" },
+ ],
+ },
+]
+
+export function HomeFooter() {
+ const year = new Date().getFullYear()
+
+ return (
+
+
+
+
+
+
+ /
+ library
+
+
+
+ Atlas was created by the Riverside Healthcare Analytics team.
+
+
+ © {year} Example Healthcare | Release 3.15.2-alpha.1
+
+
+
+
+
+
+ {footerSections.map((section) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-groups-panel.tsx b/frontend/components/home/home-groups-panel.tsx
new file mode 100644
index 000000000..b28ecc3ca
--- /dev/null
+++ b/frontend/components/home/home-groups-panel.tsx
@@ -0,0 +1,43 @@
+import Link from "next/link"
+import type { HomeGroupsPanel } from "@/lib/home/types"
+
+export function HomeGroupsPanelView({ panel }: { panel: HomeGroupsPanel }) {
+ if (panel.rows.length === 0) {
+ return (
+
+ {panel.emptyMessage}
+
+ )
+ }
+
+ return (
+
+
+
+
+ Group Name
+ Type
+ Source
+
+
+
+ {panel.rows.map((row) => (
+
+
+ {row.href ? (
+
+ {row.name}
+
+ ) : (
+ row.name
+ )}
+
+ {row.type || "-"}
+ {row.source || "-"}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-navbar-client.test.tsx b/frontend/components/home/home-navbar-client.test.tsx
new file mode 100644
index 000000000..e5d9131fa
--- /dev/null
+++ b/frontend/components/home/home-navbar-client.test.tsx
@@ -0,0 +1,24 @@
+import { fireEvent, render, screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+import { HomeNavbarClient } from "@/components/home/home-navbar-client"
+
+describe("HomeNavbarClient", () => {
+ it("opens and closes dropdown menus", () => {
+ render( )
+
+ const libraryButton = screen.getByRole("button", { name: /library menu/i })
+ expect(libraryButton).toHaveClass("cursor-pointer")
+
+ fireEvent.click(libraryButton)
+ expect(screen.getByRole("link", { name: "Collections" })).toBeInTheDocument()
+ expect(screen.getByRole("link", { name: "About Analytics" })).toBeInTheDocument()
+
+ const userButton = screen.getByRole("button", { name: /user menu/i })
+ fireEvent.click(userButton)
+ expect(screen.getByRole("link", { name: "Sign out" })).toBeInTheDocument()
+ expect(screen.getByText("Admin")).toBeInTheDocument()
+
+ fireEvent.click(userButton)
+ expect(screen.queryByRole("link", { name: "Sign out" })).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/components/home/home-navbar-client.tsx b/frontend/components/home/home-navbar-client.tsx
new file mode 100644
index 000000000..b9bb341a3
--- /dev/null
+++ b/frontend/components/home/home-navbar-client.tsx
@@ -0,0 +1,259 @@
+"use client"
+
+import { ChevronDown, List, UserRound, Wrench } from "lucide-react"
+import Link from "next/link"
+import { useEffect, useRef, useState } from "react"
+
+type HomeNavbarClientProps = {
+ displayName: string
+ isSignedIn: boolean
+ isAdministrator: boolean
+ adminEnabled: boolean
+}
+
+type MenuId = "tools" | "library" | "user" | null
+
+type DropdownItem = {
+ label: string
+ href: string
+ dividerBefore?: boolean
+}
+
+function DropdownMenu({
+ open,
+ children,
+ onMouseEnter,
+ onMouseLeave,
+}: {
+ open: boolean
+ children: React.ReactNode
+ onMouseEnter?: () => void
+ onMouseLeave?: () => void
+}) {
+ if (!open) return null
+
+ return (
+
+ {children}
+
+ )
+}
+
+function DropdownLink({ href, children }: { href: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function DropdownItems({ items }: { items: DropdownItem[] }) {
+ return items.map((item) => (
+
+ {item.dividerBefore ?
: null}
+ {item.label}
+
+ ))
+}
+
+export function HomeNavbarClient({
+ displayName,
+ isSignedIn,
+ isAdministrator,
+ adminEnabled,
+}: HomeNavbarClientProps) {
+ const [openMenu, setOpenMenu] = useState(null)
+ const containerRef = useRef(null)
+ const closeTimerRef = useRef(null)
+ const toolsItems: DropdownItem[] = [
+ { label: "Settings", href: "/settings" },
+ { label: "Analytics", href: "/analytics" },
+ { label: "Tasks", href: "/tasks" },
+ {
+ label: "Report Activity",
+ href: "/profile",
+ dividerBefore: true,
+ },
+ ]
+ const libraryItems: DropdownItem[] = [
+ { label: "Initiatives", href: "/initiatives" },
+ { label: "Collections", href: "/collections" },
+ { label: "Terms", href: "/terms" },
+ {
+ label: "About Analytics",
+ href: "/about_analytics",
+ dividerBefore: true,
+ },
+ ]
+ const userItems: DropdownItem[] = [
+ { label: "Your profile", href: "/users" },
+ { label: "Your stars", href: "/users#stars" },
+ {
+ label: "Settings",
+ href: "/users/settings",
+ dividerBefore: true,
+ },
+ ]
+
+ useEffect(() => {
+ function handleOutsideClick(event: MouseEvent) {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setOpenMenu(null)
+ }
+ }
+
+ document.addEventListener("mousedown", handleOutsideClick)
+ return () => {
+ document.removeEventListener("mousedown", handleOutsideClick)
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current)
+ }
+ }
+ }, [])
+
+ const toggleMenu = (menu: Exclude) => {
+ setOpenMenu((current) => (current === menu ? null : menu))
+ }
+
+ const openMenuOnHover = (menu: Exclude) => {
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current)
+ closeTimerRef.current = null
+ }
+ setOpenMenu(menu)
+ }
+
+ const scheduleCloseMenus = () => {
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current)
+ }
+
+ closeTimerRef.current = window.setTimeout(() => {
+ setOpenMenu(null)
+ closeTimerRef.current = null
+ }, 120)
+ }
+
+ const handleAdminToggle = () => {
+ const currentPath =
+ typeof window === "undefined"
+ ? "/"
+ : `${window.location.pathname}${window.location.search}${window.location.hash}`
+
+ window.location.assign(`/auth/admin-mode?returnTo=${encodeURIComponent(currentPath)}`)
+ }
+
+ return (
+
+
+ toggleMenu("tools")}
+ onMouseEnter={() => openMenuOnHover("tools")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+
+
+ openMenuOnHover("tools")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+
+
+
+
+ toggleMenu("library")}
+ onMouseEnter={() => openMenuOnHover("library")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+
+
+ openMenuOnHover("library")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+
+
+
+
+
toggleMenu("user")}
+ onMouseEnter={() => openMenuOnHover("user")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+
+
+
openMenuOnHover("user")}
+ onMouseLeave={scheduleCloseMenus}
+ >
+
+ {displayName}
+
+
+
+ {isSignedIn ? (
+ Sign out
+ ) : (
+ Sign in
+ )}
+
+
+
+ {isAdministrator ? (
+
+
+
+
+ Admin
+
+ ) : null}
+
+ {!isSignedIn ? (
+
+ Sign in
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/components/home/home-run-list-panel.tsx b/frontend/components/home/home-run-list-panel.tsx
new file mode 100644
index 000000000..8fe463fad
--- /dev/null
+++ b/frontend/components/home/home-run-list-panel.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link"
+import type { HomeRunListPanel } from "@/lib/home/types"
+
+export function HomeRunListPanelView({ panel }: { panel: HomeRunListPanel }) {
+ if (panel.rows.length === 0) {
+ return (
+
+ {panel.emptyMessage}
+
+ )
+ }
+
+ return (
+
+
+
+
+ Report
+ Type
+ Runs
+ Last Run
+
+
+
+ {panel.rows.map((row) => (
+
+
+ {row.href ? (
+
+ {row.name}
+
+ ) : (
+ row.name
+ )}
+
+ {row.type || "-"}
+ {typeof row.runs === "number" ? row.runs : "-"}
+ {row.lastRun || "-"}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-search-client.tsx b/frontend/components/home/home-search-client.tsx
new file mode 100644
index 000000000..2be3f78a1
--- /dev/null
+++ b/frontend/components/home/home-search-client.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import { Search } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useEffect, useState } from "react"
+
+export function HomeSearchClient({ placeholder = "type to search.." }: { placeholder?: string }) {
+ const router = useRouter()
+ const [query, setQuery] = useState("")
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (trimmed.length === 0) {
+ return
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ router.push(`/search?q=${encodeURIComponent(trimmed)}`)
+ }, 250)
+
+ return () => window.clearTimeout(timeoutId)
+ }, [query, router])
+
+ return (
+
+ )
+}
diff --git a/frontend/components/home/home-shell.tsx b/frontend/components/home/home-shell.tsx
new file mode 100644
index 000000000..410b189ae
--- /dev/null
+++ b/frontend/components/home/home-shell.tsx
@@ -0,0 +1,44 @@
+import { HomeTabsClient } from "@/components/home/home-tabs-client"
+import { LibraryShell } from "@/components/layout/library-shell"
+import type { HomeTabsVisibility } from "@/lib/home/types"
+
+type HomeShellProps = {
+ displayName: string
+ isSignedIn: boolean
+ isAdministrator: boolean
+ adminEnabled: boolean
+ visibleTabs: HomeTabsVisibility
+}
+
+export function HomeShell({
+ displayName,
+ isSignedIn,
+ isAdministrator,
+ adminEnabled,
+ visibleTabs,
+}: HomeShellProps) {
+ return (
+
+ Hi, {displayName}!
+
+ {isSignedIn ? (
+
+ ) : (
+
+
+ Sign in
+ {" "}
+ to view your stars, subscriptions, report runs, and groups.
+
+ )}
+
+ )
+}
diff --git a/frontend/components/home/home-star-card-footer.tsx b/frontend/components/home/home-star-card-footer.tsx
new file mode 100644
index 000000000..a73d48be3
--- /dev/null
+++ b/frontend/components/home/home-star-card-footer.tsx
@@ -0,0 +1,43 @@
+import { EntityCardFooter } from "@/components/interactions/entity-card-footer"
+import { ProfileAnalyticsPanelClient } from "@/components/profile/profile-analytics-panel-client"
+import type { HomeStarCard } from "@/lib/home/types"
+
+function resolveStarType(card: HomeStarCard): "collection" | "report" | null {
+ const label = card.typeLabel.toLowerCase()
+ if (label === "collection") return "collection"
+ if (label === "report") return "report"
+ return null
+}
+
+export function HomeStarCardFooter({ card }: { card: HomeStarCard }) {
+ const starType = resolveStarType(card)
+ if (!starType) return null
+
+ const profileType = starType
+
+ return (
+
+ ) : undefined
+ }
+ features={{
+ sharingEnabled: card.canShare === false ? false : undefined,
+ requestAccessEnabled: card.canRequestAccess === false ? false : undefined,
+ }}
+ />
+ )
+}
diff --git a/frontend/components/home/home-stars-panel.test.tsx b/frontend/components/home/home-stars-panel.test.tsx
new file mode 100644
index 000000000..85033c093
--- /dev/null
+++ b/frontend/components/home/home-stars-panel.test.tsx
@@ -0,0 +1,102 @@
+import { render, screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+import { HomeStarsPanelView } from "@/components/home/home-stars-panel"
+
+describe("HomeStarsPanelView", () => {
+ it("renders richer card content with tags, image, and footer actions", () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText("Executive Dashboard")).toBeInTheDocument()
+ expect(screen.getByText("Analytics Certified")).toBeInTheDocument()
+ expect(screen.getByRole("img", { name: "Executive Dashboard thumbnail" })).toBeInTheDocument()
+ expect(screen.getByRole("link", { name: "Run report" })).toBeInTheDocument()
+ expect(screen.getByRole("link", { name: "Edit" })).toBeInTheDocument()
+ expect(screen.getByRole("link", { name: "Manage" })).toBeInTheDocument()
+ expect(screen.getByLabelText("Open report profile")).toBeInTheDocument()
+ })
+
+ it("renders the plain empty-state message when there are no favorites or suggestions", () => {
+ render(
+ ,
+ )
+
+ expect(
+ screen.getByText("You don't have any favorites! Search to get started."),
+ ).toBeInTheDocument()
+ })
+
+ it("renders the Razor-style suggestion fallback when suggested reports are present", () => {
+ render(
+ ,
+ )
+
+ expect(
+ screen.getByText("You don't have any favorites! Here's some reports you've used."),
+ ).toBeInTheDocument()
+ expect(screen.getByText("Operations Summary")).toBeInTheDocument()
+ })
+})
diff --git a/frontend/components/home/home-stars-panel.tsx b/frontend/components/home/home-stars-panel.tsx
new file mode 100644
index 000000000..7a0b4cbe7
--- /dev/null
+++ b/frontend/components/home/home-stars-panel.tsx
@@ -0,0 +1,224 @@
+import {
+ BadgeCheck,
+ BookOpen,
+ ChartBar,
+ Folder,
+ FolderOpen,
+ Lightbulb,
+ PlayCircle,
+ Search,
+ UserRound,
+ Users,
+ Waypoints,
+} from "lucide-react"
+import Link from "next/link"
+import { HomeStarCardFooter } from "@/components/home/home-star-card-footer"
+import type { HomeStarCard, HomeStarsPanel } from "@/lib/home/types"
+
+function filterIcon(label: string) {
+ switch (label) {
+ case "Reports":
+ return
+ case "Collections":
+ return
+ case "Initiatives":
+ return
+ case "Terms":
+ return
+ case "Users":
+ return
+ case "Groups":
+ return
+ default:
+ return
+ }
+}
+
+function renderDetailsLink(card: HomeStarCard, className: string, labelClassName: string) {
+ if (card.canOpenDetails) {
+ return (
+
+ {card.title}
+
+ )
+ }
+
+ return {card.title}
+}
+
+function isCollectionCard(card: HomeStarCard) {
+ return card.typeLabel.toLowerCase() === "collection"
+}
+
+function isReportCard(card: HomeStarCard) {
+ return card.typeLabel.toLowerCase() === "report"
+}
+
+export function HomeStarsPanelView({ panel }: { panel: HomeStarsPanel }) {
+ const showFolders = panel.cards.length > 0 && !panel.isSuggestionFallback
+
+ return (
+
+ {panel.filters.length > 0 ? (
+
+
+
+ Quick Filter
+
+
+ {panel.filters.map((filter) => (
+
+ {filterIcon(filter.label)}
+ {filter.label}
+
+ ))}
+
+ ) : null}
+
+
+ {showFolders ? (
+
+ ) : null}
+
+
+ {panel.isSuggestionFallback && panel.suggestionHeading ? (
+
+ {panel.suggestionHeading}
+
+ ) : null}
+ {panel.cards.length > 0 ? (
+ panel.cards.map((card) => {
+ const collectionCard = isCollectionCard(card)
+ const reportCard = isReportCard(card)
+
+ return (
+
+
+
+ {reportCard ? (
+ card.canRun && card.runUrl ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : null}
+
+ {renderDetailsLink(
+ card,
+ "atlas-home-card-title hover:underline",
+ "atlas-home-card-title",
+ )}
+ {card.isCertified ? (
+
+
+
+ ) : null}
+
+
+
+
+ {card.typeLabel}
+
+ {card.tags
+ ?.filter((tag) => tag.showInHeader)
+ .map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+
+
+
+ {card.thumbnailUrl || card.placeholderImageUrl ? (
+ // biome-ignore lint/performance/noImgElement: homepage parity uses backend-provided report thumbnails directly.
+
+ ) : (
+
+ )}
+
+
+
{card.description}
+
+ {card.canOpenDetails ? (
+
+ read more
+
+ ) : (
+ "Open to view details."
+ )}
+
+
+
+
+
+
+ )
+ })
+ ) : (
+
+ {panel.emptyMessage}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-subscriptions-panel.tsx b/frontend/components/home/home-subscriptions-panel.tsx
new file mode 100644
index 000000000..0d6f330ec
--- /dev/null
+++ b/frontend/components/home/home-subscriptions-panel.tsx
@@ -0,0 +1,40 @@
+import type { HomeSubscriptionsPanel } from "@/lib/home/types"
+
+export function HomeSubscriptionsPanelView({ panel }: { panel: HomeSubscriptionsPanel }) {
+ if (panel.rows.length === 0) {
+ return (
+
+ {panel.emptyMessage}
+
+ )
+ }
+
+ return (
+
+
+
+
+ Report
+ Subscription Description
+ Last Run
+ Message
+ Subscribed As
+
+
+
+ {panel.rows.map((row) => (
+
+
+ {row.name}
+
+ {row.description || "-"}
+ {row.lastRun || "Never"}
+ {row.lastStatus || "-"}
+ {row.sentTo || "-"}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/home/home-tab-panel.tsx b/frontend/components/home/home-tab-panel.tsx
new file mode 100644
index 000000000..b18ed4f91
--- /dev/null
+++ b/frontend/components/home/home-tab-panel.tsx
@@ -0,0 +1,18 @@
+import { HomeGroupsPanelView } from "@/components/home/home-groups-panel"
+import { HomeRunListPanelView } from "@/components/home/home-run-list-panel"
+import { HomeStarsPanelView } from "@/components/home/home-stars-panel"
+import { HomeSubscriptionsPanelView } from "@/components/home/home-subscriptions-panel"
+import type { HomePanelData } from "@/lib/home/types"
+
+export function HomeTabPanel({ panel }: { panel: HomePanelData }) {
+ switch (panel.kind) {
+ case "stars":
+ return
+ case "subscriptions":
+ return
+ case "report-runs":
+ return
+ case "groups":
+ return
+ }
+}
diff --git a/frontend/components/home/home-tabs-client.test.tsx b/frontend/components/home/home-tabs-client.test.tsx
new file mode 100644
index 000000000..444839cb4
--- /dev/null
+++ b/frontend/components/home/home-tabs-client.test.tsx
@@ -0,0 +1,106 @@
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { HomeTabsClient } from "@/components/home/home-tabs-client"
+
+const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise>()
+
+describe("HomeTabsClient", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ fetchMock.mockReset()
+ })
+
+ it("loads the default stars tab and caches loaded tabs", async () => {
+ fetchMock.mockImplementation((input) => {
+ const url = String(input)
+ if (url.endsWith("/api/home/stars")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ summary: { totalCount: 2, unsortedCount: 0 },
+ filters: {
+ hasReports: true,
+ hasCollections: false,
+ hasInitiatives: false,
+ hasTerms: false,
+ hasUsers: false,
+ hasGroups: false,
+ hasSearches: false,
+ },
+ folders: [],
+ items: [
+ {
+ starId: 1,
+ itemId: 1,
+ url: "/reports?id=1",
+ name: "Executive Dashboard",
+ typeLabel: "Report",
+ description: "Used for leadership review.",
+ starCount: 3,
+ },
+ ],
+ suggestedReports: [],
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ },
+ ),
+ )
+ }
+
+ if (url.endsWith("/api/home/groups")) {
+ return Promise.resolve(
+ new Response(JSON.stringify([{ id: 12, name: "Finance", type: "AD", source: "LDAP" }]), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ )
+ }
+
+ return Promise.resolve(
+ new Response(JSON.stringify({ error: "unknown" }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ }),
+ )
+ })
+
+ render( )
+
+ expect(screen.getByRole("tab", { name: "Stars" })).toHaveAttribute("aria-selected", "true")
+ expect(screen.getByText("Loading Stars...")).toBeInTheDocument()
+
+ await screen.findByText("Executive Dashboard")
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ await userEvent.click(screen.getByRole("tab", { name: "Groups" }))
+ await screen.findByText("Finance")
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+
+ await userEvent.click(screen.getByRole("tab", { name: "Stars" }))
+ await waitFor(() => {
+ expect(screen.getByText("Executive Dashboard")).toBeInTheDocument()
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it("shows the sign-in prompt for unauthorized tab responses", async () => {
+ fetchMock.mockResolvedValue(
+ new Response(JSON.stringify({ ok: false, error: "auth_required" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ }),
+ )
+
+ render( )
+
+ await screen.findByRole("link", { name: "Sign in" })
+ expect(screen.getByText("to view your stars.", { exact: false })).toBeInTheDocument()
+ })
+})
diff --git a/frontend/components/home/home-tabs-client.tsx b/frontend/components/home/home-tabs-client.tsx
new file mode 100644
index 000000000..a5e35868f
--- /dev/null
+++ b/frontend/components/home/home-tabs-client.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { HomeTabPanel } from "@/components/home/home-tab-panel"
+import { fetchHomeTabPanel } from "@/lib/home/client-api"
+import { HOME_TABS } from "@/lib/home/constants"
+import type {
+ HomePanelData,
+ HomeTabId,
+ HomeTabsVisibility,
+} from "@/lib/home/types"
+
+type PanelState = {
+ status: "idle" | "loading" | "ready" | "error"
+ data?: HomePanelData
+ error?: string
+}
+
+type HomeTabsClientProps = {
+ visibleTabs?: HomeTabsVisibility
+}
+
+function getDefaultTab(visibleTabs: HomeTabsVisibility): HomeTabId {
+ return HOME_TABS.find((tab) => visibleTabs[tab.id])?.id ?? "stars"
+}
+
+export function HomeTabsClient({
+ visibleTabs = {
+ stars: true,
+ subscriptions: true,
+ "report-runs": true,
+ groups: true,
+ },
+}: HomeTabsClientProps) {
+ const [activeTab, setActiveTab] = useState(getDefaultTab(visibleTabs))
+ const [panels, setPanels] = useState>({
+ stars: { status: "idle" },
+ subscriptions: { status: "idle" },
+ "report-runs": { status: "idle" },
+ groups: { status: "idle" },
+ })
+
+ useEffect(() => {
+ const state = panels[activeTab]
+ if (state.status !== "idle") return
+
+ setPanels((current) => ({
+ ...current,
+ [activeTab]: { status: "loading" },
+ }))
+
+ fetchHomeTabPanel(activeTab)
+ .then((payload) => {
+ if (!payload.ok) {
+ setPanels((current) => ({
+ ...current,
+ [activeTab]: { status: "error", error: payload.error },
+ }))
+ return
+ }
+
+ setPanels((current) => ({
+ ...current,
+ [activeTab]: { status: "ready", data: payload.data },
+ }))
+ })
+ .catch(() => {
+ setPanels((current) => ({
+ ...current,
+ [activeTab]: { status: "error", error: "unknown" },
+ }))
+ })
+ }, [activeTab, panels])
+
+ const tabs = HOME_TABS.filter((tab) => visibleTabs[tab.id])
+ const activePanel = panels[activeTab]
+
+ return (
+
+
+
+ {tabs.map((tab, index) => (
+
+ {index > 0 ? (
+ /
+ ) : null}
+ setActiveTab(tab.id)}
+ >
+ {tab.label}
+
+
+ ))}
+
+
+
+ tab.id === activeTab)?.label}>
+ {activePanel.status === "loading" || activePanel.status === "idle" ? (
+
+ Loading {HOME_TABS.find((tab) => tab.id === activeTab)?.label}...
+
+ ) : null}
+
+ {activePanel.status === "error" ? (
+
+ {activePanel.error === "http_401" ? (
+ <>
+
+ Sign in
+ {" "}
+ to view your {HOME_TABS.find((tab) => tab.id === activeTab)?.label?.toLowerCase()}.
+ >
+ ) : (
+ <>Unable to load {HOME_TABS.find((tab) => tab.id === activeTab)?.label}.>
+ )}
+
+ ) : null}
+
+ {activePanel.status === "ready" && activePanel.data ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/components/interactions/action-rail.tsx b/frontend/components/interactions/action-rail.tsx
new file mode 100644
index 000000000..94125a00a
--- /dev/null
+++ b/frontend/components/interactions/action-rail.tsx
@@ -0,0 +1,135 @@
+"use client"
+
+import Link from "next/link"
+import type { ReactNode } from "react"
+import { Button } from "@/components/ui/button"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+
+export function ActionRail({
+ label,
+ children,
+ className,
+}: {
+ label: string
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+
+
+ )
+}
+
+export function ActionRailGroup({
+ children,
+ separated = false,
+}: {
+ children: ReactNode
+ separated?: boolean
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function RailIconLink({
+ href,
+ label,
+ children,
+ className,
+}: {
+ href: string
+ label: string
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+
+
+ {children}
+
+
+ {label}
+
+ )
+}
+
+export function RailExternalLink({
+ href,
+ label,
+ children,
+ className,
+}: {
+ href: string
+ label: string
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+ {label}
+
+ )
+}
+
+export function RailTooltipButton({
+ label,
+ children,
+ disabled,
+ onClick,
+ className,
+}: {
+ label: string
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ className?: string
+}) {
+ return (
+
+
+
+ {children}
+
+
+ {label}
+
+ )
+}
diff --git a/frontend/components/interactions/entity-card-footer.tsx b/frontend/components/interactions/entity-card-footer.tsx
new file mode 100644
index 000000000..ad0cb1a82
--- /dev/null
+++ b/frontend/components/interactions/entity-card-footer.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import { Pencil, Settings } from "lucide-react"
+import type { ReactNode } from "react"
+import { EntityProfileSheet } from "@/components/interactions/entity-profile-sheet"
+import { FooterIconAction, FooterIconActions } from "@/components/interactions/footer-icon-actions"
+import { RequestAccessDialog } from "@/components/interactions/request-access-dialog"
+import { ShareMailDialog } from "@/components/interactions/share-mail-dialog"
+import { StarToggleButton } from "@/components/interactions/star-toggle-button"
+import type { CollectionFeatureFlagsDto } from "@/lib/collections/types"
+import { isInteractionFeatureEnabled } from "@/lib/interactions/features"
+import type { ReportFeatureFlags } from "@/lib/reports/types"
+
+type InteractionFeatures = CollectionFeatureFlagsDto | ReportFeatureFlags | undefined
+
+function readFeatureFlag(
+ features: InteractionFeatures,
+ key: "sharingEnabled" | "requestAccessEnabled",
+) {
+ const value = features?.[key]
+ return typeof value === "boolean" ? value : undefined
+}
+
+function renderFooterLink(href: string, icon: ReactNode, label: string) {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
+
+export type EntityCardFooterProps = {
+ entityType: "collection" | "report"
+ id: number
+ title: string
+ href: string
+ isStarred?: boolean
+ starCount?: number
+ features?: InteractionFeatures
+ canEdit?: boolean
+ editUrl?: string | null
+ canManage?: boolean
+ manageUrl?: string | null
+ canOpenProfile?: boolean
+ canRequestAccess?: boolean
+ profilePanel?: ReactNode
+}
+
+export function EntityCardFooter({
+ entityType,
+ id,
+ title,
+ href,
+ isStarred = false,
+ starCount = 0,
+ features,
+ canEdit = false,
+ editUrl,
+ canManage = false,
+ manageUrl,
+ canOpenProfile = true,
+ canRequestAccess = false,
+ profilePanel,
+}: EntityCardFooterProps) {
+ const starType = entityType
+ const profileLabel = entityType === "collection" ? "collection profile" : "report profile"
+ const sharingEnabled = isInteractionFeatureEnabled(readFeatureFlag(features, "sharingEnabled"))
+ const requestAccessEnabled =
+ isInteractionFeatureEnabled(readFeatureFlag(features, "requestAccessEnabled")) &&
+ canRequestAccess
+
+ const starCell = (
+
+ )
+
+ if (entityType === "collection") {
+ return (
+
+ {starCell}
+
+ {canOpenProfile && profilePanel ? (
+
+
+ {profilePanel}
+
+
+ ) : null}
+ {sharingEnabled ? (
+
+
+
+ ) : null}
+
+
+ )
+ }
+
+ const segments: ReactNode[] = [starCell]
+
+ if (canEdit && editUrl) {
+ segments.push(
+ renderFooterLink(editUrl, , "Edit"),
+ )
+ }
+
+ if (canManage && manageUrl) {
+ segments.push(
+ renderFooterLink(manageUrl, , "Manage"),
+ )
+ }
+
+ const hasIconActions = (canOpenProfile && profilePanel) || sharingEnabled || requestAccessEnabled
+
+ if (hasIconActions) {
+ segments.push(
+
+ {canOpenProfile && profilePanel ? (
+
+
+ {profilePanel}
+
+
+ ) : null}
+ {sharingEnabled ? (
+
+
+
+ ) : null}
+ {requestAccessEnabled ? (
+
+
+
+ ) : null}
+ ,
+ )
+ }
+
+ const columnClass =
+ segments.length === 2
+ ? "grid-cols-2"
+ : segments.length === 3
+ ? "grid-cols-3"
+ : "grid-cols-2 md:grid-cols-4"
+
+ return (
+
+ {segments}
+
+ )
+}
diff --git a/frontend/components/interactions/entity-engagement-rail-actions.tsx b/frontend/components/interactions/entity-engagement-rail-actions.tsx
new file mode 100644
index 000000000..57de15c37
--- /dev/null
+++ b/frontend/components/interactions/entity-engagement-rail-actions.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { EntityFeedbackDialog } from "@/components/interactions/entity-feedback-dialog"
+import { EntityProfileSheet } from "@/components/interactions/entity-profile-sheet"
+import { RequestAccessDialog } from "@/components/interactions/request-access-dialog"
+import { ShareMailDialog } from "@/components/interactions/share-mail-dialog"
+import { StarToggleButton } from "@/components/interactions/star-toggle-button"
+import { isInteractionFeatureEnabled } from "@/lib/interactions/features"
+import type { InteractionEntityType } from "@/lib/interactions/types"
+
+type EngagementFeatures = {
+ sharingEnabled?: boolean
+ feedbackEnabled?: boolean
+ requestAccessEnabled?: boolean
+}
+
+export function EntityEngagementRailActions({
+ entityType,
+ entityId,
+ entityName,
+ entityUrl,
+ profileLabel,
+ profilePanel,
+ isStarred,
+ starCount,
+ features,
+ showRequestAccess = false,
+ afterStar,
+}: {
+ entityType: InteractionEntityType
+ entityId: number
+ entityName: string
+ entityUrl: string
+ profileLabel: string
+ profilePanel: ReactNode
+ isStarred?: boolean
+ starCount?: number
+ features?: EngagementFeatures
+ showRequestAccess?: boolean
+ afterStar?: ReactNode
+}) {
+ return (
+ <>
+
+ {profilePanel}
+
+
+
+
+ {afterStar}
+
+ {isInteractionFeatureEnabled(features?.sharingEnabled) ? (
+
+ ) : null}
+
+ {showRequestAccess && isInteractionFeatureEnabled(features?.requestAccessEnabled) ? (
+
+ ) : null}
+
+ {isInteractionFeatureEnabled(features?.feedbackEnabled) ? (
+
+ ) : null}
+ >
+ )
+}
diff --git a/frontend/components/interactions/entity-feedback-dialog.tsx b/frontend/components/interactions/entity-feedback-dialog.tsx
new file mode 100644
index 000000000..dd7a4bc4f
--- /dev/null
+++ b/frontend/components/interactions/entity-feedback-dialog.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { ThumbsUp } from "lucide-react"
+import { FeedbackForm } from "@/components/interactions/feedback-form"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+
+export function EntityFeedbackDialog({
+ entityName,
+ entityUrl,
+ variant = "default",
+}: {
+ entityName: string
+ entityUrl: string
+ variant?: "default" | "footer"
+}) {
+ const tooltipPlacement = variant === "footer" ? "footer" : "rail"
+ const trigger =
+ variant === "footer" ? (
+
+
+ Share feedback
+
+ ) : (
+
+
+ Share feedback
+
+ )
+
+ return (
+
+
+ {trigger}
+
+
+
+ Share feedback
+
+
+
+
+ )
+}
diff --git a/frontend/components/interactions/entity-profile-sheet.tsx b/frontend/components/interactions/entity-profile-sheet.tsx
new file mode 100644
index 000000000..acc0d61b6
--- /dev/null
+++ b/frontend/components/interactions/entity-profile-sheet.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { BarChart3 } from "lucide-react"
+import type { ReactNode } from "react"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
+
+export function EntityProfileSheet({
+ entityName,
+ entityLabel = "profile",
+ children,
+ variant = "default",
+}: {
+ entityName: string
+ entityLabel?: string
+ children: ReactNode
+ variant?: "default" | "footer"
+}) {
+ const tooltipLabel = `Open ${entityLabel}`
+ const tooltipPlacement = variant === "footer" ? "footer" : "rail"
+ const trigger =
+ variant === "footer" ? (
+
+
+ {tooltipLabel}
+
+ ) : (
+
+
+ {tooltipLabel}
+
+ )
+
+ return (
+
+
+ {trigger}
+
+
+
+ Profile — {entityName}
+
+ {children}
+
+
+ )
+}
diff --git a/frontend/components/interactions/feedback-form.tsx b/frontend/components/interactions/feedback-form.tsx
new file mode 100644
index 000000000..595ff445c
--- /dev/null
+++ b/frontend/components/interactions/feedback-form.tsx
@@ -0,0 +1,77 @@
+"use client"
+
+import { useState, useTransition } from "react"
+import { submitFeedbackAction } from "@/app/interactions/actions"
+import { Button } from "@/components/ui/button"
+import { Label } from "@/components/ui/label"
+
+export function FeedbackForm({
+ entityName,
+ entityUrl,
+ reportName,
+ reportUrl,
+}: {
+ entityName?: string
+ entityUrl?: string
+ /** @deprecated Use entityName */
+ reportName?: string
+ /** @deprecated Use entityUrl */
+ reportUrl?: string
+}) {
+ const name = entityName ?? reportName ?? ""
+ const url = entityUrl ?? reportUrl ?? ""
+ const [description, setDescription] = useState("")
+ const [message, setMessage] = useState(null)
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ return (
+
+ )
+}
diff --git a/frontend/components/interactions/footer-icon-actions.tsx b/frontend/components/interactions/footer-icon-actions.tsx
new file mode 100644
index 000000000..24ab3988c
--- /dev/null
+++ b/frontend/components/interactions/footer-icon-actions.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+
+export function FooterIconActions({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function FooterIconAction({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/components/interactions/interaction-tooltip.tsx b/frontend/components/interactions/interaction-tooltip.tsx
new file mode 100644
index 000000000..259ed7c05
--- /dev/null
+++ b/frontend/components/interactions/interaction-tooltip.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+export type InteractionTooltipPlacement = "rail" | "footer"
+
+function tooltipSide(placement: InteractionTooltipPlacement): "right" | "top" {
+ return placement === "footer" ? "top" : "right"
+}
+
+export function InteractionTooltip({
+ label,
+ placement = "rail",
+ children,
+}: {
+ label: string
+ placement?: InteractionTooltipPlacement
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+ {label}
+
+ )
+}
diff --git a/frontend/components/interactions/report-run-dialog.tsx b/frontend/components/interactions/report-run-dialog.tsx
new file mode 100644
index 000000000..0d7e3e7fa
--- /dev/null
+++ b/frontend/components/interactions/report-run-dialog.tsx
@@ -0,0 +1,115 @@
+"use client"
+
+import { CirclePlay } from "lucide-react"
+import type { ReactNode } from "react"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import type { ReportAttachment } from "@/lib/reports/types"
+
+function formatAttachmentDate(value?: string | null): string {
+ if (!value) return ""
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString()
+}
+
+export function ReportRunDialog({
+ reportName,
+ attachments,
+ trigger,
+}: {
+ reportName: string
+ attachments: ReportAttachment[]
+ trigger?: ReactNode
+}) {
+ const sorted = [...attachments].sort((a, b) => {
+ const aTime = a.creationDate ? new Date(a.creationDate).getTime() : 0
+ const bTime = b.creationDate ? new Date(b.creationDate).getTime() : 0
+ return bTime - aTime
+ })
+
+ return (
+
+
+
+ {trigger ?? (
+
+
+ Run report
+
+ )}
+
+
+
+
+ Run this report
+
+
+
+ This is a Crystal Report. Here are the recent run outputs for{" "}
+ {reportName} .
+
+ {sorted.length > 0 ? (
+
+ {sorted.map((attachment) => {
+ const label = attachment.name?.trim() || `Output ${attachment.id}`
+ const dateLabel = formatAttachmentDate(attachment.creationDate)
+ const href = attachment.runUrl?.trim()
+
+ return (
+
+ {href ? (
+
+ {label}
+
+ ) : (
+ {label}
+ )}
+ {dateLabel ? (
+ · {dateLabel}
+ ) : null}
+
+ )
+ })}
+
+ ) : (
+
No run outputs are available yet.
+ )}
+
+
+ If you need a newer {" "}
+ dataset, request a new run from the Report Library.
+
+
+ Open the Report Library
+ Search for this report
+ Click "view" > click "Request"
+
+
~ after 5 minutes ~
+
+ Open My Reports
+ In the lower section of the screen you will find the report listed
+ Click "View Output"
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/interactions/report-snippet-run-action.tsx b/frontend/components/interactions/report-snippet-run-action.tsx
new file mode 100644
index 000000000..cc527ecfe
--- /dev/null
+++ b/frontend/components/interactions/report-snippet-run-action.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+import { CirclePlay } from "lucide-react"
+import Link from "next/link"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+
+function resolveDisabledRunLabel(report: {
+ epicMasterFile?: string | null
+ editReportUrl?: string | null
+}): string {
+ if (report.epicMasterFile === "IDB") {
+ return "Open a related dashboard that uses this."
+ }
+ if (report.editReportUrl) {
+ return "Open in report library."
+ }
+ if (report.epicMasterFile) {
+ return "Run from the Hyperspace report library."
+ }
+ return "Run report unavailable"
+}
+
+export function ReportSnippetRunAction({
+ reportId,
+ canRun = false,
+ attachmentCount = 0,
+ runUrl,
+ epicMasterFile,
+ editReportUrl,
+}: {
+ reportId: number
+ canRun?: boolean
+ attachmentCount?: number
+ runUrl?: string | null
+ epicMasterFile?: string | null
+ editReportUrl?: string | null
+}) {
+ const detailHref = `/reports?id=${reportId}`
+ const showRun = attachmentCount > 0 || canRun || !!epicMasterFile || !!editReportUrl
+
+ if (!showRun) {
+ return null
+ }
+
+ const playIcon = (enabled: boolean) => (
+
+ )
+
+ if (runUrl) {
+ return (
+
+
+ {playIcon(true)}
+
+
+ )
+ }
+
+ if (canRun || attachmentCount > 0) {
+ return (
+
+
+ {playIcon(true)}
+
+
+ )
+ }
+
+ return (
+
+
+ {playIcon(false)}
+
+
+ )
+}
diff --git a/frontend/components/interactions/request-access-dialog.tsx b/frontend/components/interactions/request-access-dialog.tsx
new file mode 100644
index 000000000..2b65d8720
--- /dev/null
+++ b/frontend/components/interactions/request-access-dialog.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import { Accessibility } from "lucide-react"
+import { useCallback, useEffect, useState, useTransition } from "react"
+import { searchRecipientsAction, submitAccessRequestAction } from "@/app/interactions/actions"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+
+export function RequestAccessDialog({
+ reportName,
+ reportUrl,
+ iconOnly = true,
+ variant = "default",
+}: {
+ reportName: string
+ reportUrl: string
+ iconOnly?: boolean
+ variant?: "default" | "footer"
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState("")
+ const [directorName, setDirectorName] = useState("")
+ const [suggestions, setSuggestions] = useState<
+ Array<{ id: number; name: string; type: string; email?: string | null }>
+ >([])
+ const [status, setStatus] = useState(null)
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ const fetcher = useCallback((q: string) => searchRecipientsAction(q, false), [])
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (!trimmed) {
+ setSuggestions([])
+ return
+ }
+ const handle = window.setTimeout(() => {
+ void fetcher(trimmed).then(setSuggestions)
+ }, 280)
+ return () => {
+ window.clearTimeout(handle)
+ }
+ }, [query, fetcher])
+
+ const tooltipPlacement = variant === "footer" ? "footer" : "rail"
+
+ const trigger =
+ iconOnly && variant === "footer" ? (
+
+
+ Request access
+
+ ) : iconOnly ? (
+
+
+ Request access
+
+ ) : (
+
+ Request access
+
+ )
+
+ return (
+
+
+ {trigger}
+
+
+
+ Request report access
+
+ {
+ event.preventDefault()
+ setError(null)
+ setStatus(null)
+ const trimmedDirector = directorName.trim()
+ if (!trimmedDirector) {
+ setError("Director is required.")
+ return
+ }
+ startTransition(() => {
+ void (async () => {
+ const result = await submitAccessRequestAction({
+ reportName,
+ reportUrl,
+ directorName: trimmedDirector,
+ })
+ if (result.error) {
+ setError(result.error)
+ return
+ }
+ setStatus("Your request has been submitted.")
+ setDirectorName("")
+ setQuery("")
+ setSuggestions([])
+ })()
+ })
+ }}
+ >
+ {reportName}
+
+
Find your director
+
setQuery(event.target.value)}
+ placeholder="Type to search…"
+ autoComplete="off"
+ />
+ {suggestions.length > 0 ? (
+
+ {suggestions.map((item) => (
+
+ {
+ setDirectorName(item.name)
+ setQuery(item.name)
+ setSuggestions([])
+ }}
+ >
+ {item.name}
+ {item.email ? (
+ {item.email}
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+ {directorName ? (
+
+ Selected director: {directorName}
+
+ ) : null}
+
+ {error ? {error}
: null}
+ {status ? {status}
: null}
+
+ {pending ? "Submitting…" : "Request access"}
+
+
+
+
+ )
+}
diff --git a/frontend/components/interactions/share-mail-dialog.tsx b/frontend/components/interactions/share-mail-dialog.tsx
new file mode 100644
index 000000000..7e1a664ad
--- /dev/null
+++ b/frontend/components/interactions/share-mail-dialog.tsx
@@ -0,0 +1,225 @@
+"use client"
+
+import { Share2 } from "lucide-react"
+import { useCallback, useEffect, useState, useTransition } from "react"
+import { searchRecipientsAction, sendShareMailAction } from "@/app/interactions/actions"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import type { ShareRecipientInput } from "@/lib/interactions/types"
+
+type SelectedRecipient = ShareRecipientInput & { name: string }
+
+export function ShareMailDialog({
+ shareName,
+ shareUrl,
+ iconOnly = false,
+ variant = "default",
+}: {
+ shareName: string
+ shareUrl: string
+ iconOnly?: boolean
+ variant?: "default" | "footer"
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState("")
+ const [suggestions, setSuggestions] = useState<
+ Array<{ id: number; name: string; type: string; email?: string | null }>
+ >([])
+ const [selected, setSelected] = useState([])
+ const [subject, setSubject] = useState(`[Share] ${shareName}`)
+ const [message, setMessage] = useState("")
+ const [status, setStatus] = useState(null)
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ const fetcher = useCallback((q: string) => searchRecipientsAction(q, true), [])
+
+ useEffect(() => {
+ if (!open) return
+ setSubject(`[Share] ${shareName}`)
+ setMessage(
+ `Hi!\n\nI would like to share this report with you.\n\n[${shareName}](${shareUrl})\n\nCheck it out sometime!\nRegards!`,
+ )
+ setError(null)
+ setStatus(null)
+ }, [open, shareName, shareUrl])
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (!trimmed) {
+ setSuggestions([])
+ return
+ }
+ const handle = window.setTimeout(() => {
+ void fetcher(trimmed).then(setSuggestions)
+ }, 280)
+ return () => {
+ window.clearTimeout(handle)
+ }
+ }, [query, fetcher])
+
+ const addRecipient = (item: { id: number; name: string; type: string }) => {
+ const recipientType = item.type.toLowerCase().startsWith("g") ? "g" : "u"
+ setSelected((prev) => {
+ if (prev.some((r) => r.userId === item.id && r.type === recipientType)) return prev
+ return [...prev, { userId: item.id, type: recipientType, name: item.name }]
+ })
+ setQuery("")
+ setSuggestions([])
+ }
+
+ const tooltipPlacement = variant === "footer" ? "footer" : "rail"
+
+ const trigger = iconOnly ? (
+ variant === "footer" ? (
+
+
+ Share
+
+ ) : (
+
+
+ Share
+
+ )
+ ) : (
+
+ Share
+
+ )
+
+ return (
+
+
+ {trigger}
+
+
+
+ Share {shareName}
+
+
+
+
Recipients
+
{
+ setQuery(e.target.value)
+ }}
+ placeholder="Search users or groups…"
+ autoComplete="off"
+ />
+ {suggestions.length > 0 ? (
+
+ {suggestions.map((item) => (
+
+ {
+ addRecipient(item)
+ }}
+ >
+ {item.name}
+ {item.email ? (
+ {item.email}
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+ {selected.length > 0 ? (
+
+ {selected.map((r) => (
+
+ {r.name}
+ {
+ setSelected((prev) =>
+ prev.filter((x) => !(x.userId === r.userId && x.type === r.type)),
+ )
+ }}
+ >
+ ×
+
+
+ ))}
+
+ ) : null}
+
+
+ Subject
+ {
+ setSubject(e.target.value)
+ }}
+ />
+
+
+ Message
+ {
+ setMessage(e.target.value)
+ }}
+ rows={4}
+ className="w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
+ />
+
+ {error ?
{error}
: null}
+ {status ?
{status}
: null}
+
{
+ setError(null)
+ setStatus(null)
+ const text = message.trim()
+ startTransition(() => {
+ void (async () => {
+ const result = await sendShareMailAction({
+ to: selected.map(({ userId, type }) => ({ userId, type })),
+ subject: subject.trim() || `[Share] ${shareName}`,
+ message: text ? `${text}
` : `Shared via Atlas Library
`,
+ text: text || "Shared via Atlas Library",
+ share: true,
+ shareName,
+ shareUrl,
+ })
+ if (result.error) {
+ setError(result.error)
+ return
+ }
+ setStatus(result.data?.message ?? "Successfully shared.")
+ })()
+ })
+ }}
+ >
+ {pending ? "Sending…" : "Send"}
+
+
+
+
+ )
+}
diff --git a/frontend/components/interactions/star-toggle-button.tsx b/frontend/components/interactions/star-toggle-button.tsx
new file mode 100644
index 000000000..f59f6069f
--- /dev/null
+++ b/frontend/components/interactions/star-toggle-button.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import { Star } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useState, useTransition } from "react"
+import { toggleStarAction } from "@/app/interactions/actions"
+import { InteractionTooltip } from "@/components/interactions/interaction-tooltip"
+import { Button } from "@/components/ui/button"
+import type { InteractionEntityType } from "@/lib/interactions/types"
+
+export function StarToggleButton({
+ type,
+ id,
+ initialStarred,
+ initialCount,
+ iconOnly = false,
+ variant = "button",
+}: {
+ type: InteractionEntityType
+ id: number
+ initialStarred: boolean
+ initialCount: number
+ iconOnly?: boolean
+ variant?: "button" | "card-footer"
+}) {
+ const router = useRouter()
+ const [isStarred, setIsStarred] = useState(initialStarred)
+ const [count, setCount] = useState(initialCount)
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ const toggleStar = () => {
+ setError(null)
+ startTransition(() => {
+ void (async () => {
+ const result = await toggleStarAction(type, id)
+ if (result.error) {
+ setError(result.error)
+ return
+ }
+ if (result.data) {
+ setIsStarred(result.data.isStarred)
+ setCount(result.data.count)
+ }
+ router.refresh()
+ })()
+ })
+ }
+
+ if (variant === "card-footer") {
+ return (
+
+
+
+
+ {isStarred ? "Starred" : "Star"} {count}
+
+
+ {error ? {error} : null}
+
+ )
+ }
+
+ const entityLabel = type === "report" ? "report" : "collection"
+ const starTooltip = `Star this ${entityLabel}`
+
+ if (iconOnly) {
+ return (
+
+
+
+
+ {starTooltip}
+ {count > 0 ? (
+
+ {count}
+
+ ) : null}
+
+
+ {error ? {error} : null}
+
+ )
+ }
+
+ return (
+
+
+
+ {count}
+
+ {error ? {error} : null}
+
+ )
+}
diff --git a/frontend/components/layout/library-shell.tsx b/frontend/components/layout/library-shell.tsx
new file mode 100644
index 000000000..5d297a191
--- /dev/null
+++ b/frontend/components/layout/library-shell.tsx
@@ -0,0 +1,61 @@
+import Image from "next/image"
+import Link from "next/link"
+import type { ReactNode } from "react"
+import { HomeFooter } from "@/components/home/home-footer"
+import { HomeNavbarClient } from "@/components/home/home-navbar-client"
+import { HomeSearchClient } from "@/components/home/home-search-client"
+
+type LibraryShellProps = {
+ displayName: string
+ isSignedIn: boolean
+ isAdministrator: boolean
+ adminEnabled: boolean
+ searchPlaceholder?: string
+ children: ReactNode
+}
+
+export function LibraryShell({
+ displayName,
+ isSignedIn,
+ isAdministrator,
+ adminEnabled,
+ searchPlaceholder = "type to search..",
+ children,
+}: LibraryShellProps) {
+ return (
+
+
+
+
+
+
+ /
+ library
+
+
+
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-analytics-panel-client.tsx b/frontend/components/profile/profile-analytics-panel-client.tsx
new file mode 100644
index 000000000..4816f7955
--- /dev/null
+++ b/frontend/components/profile/profile-analytics-panel-client.tsx
@@ -0,0 +1,15 @@
+"use client"
+
+import { ProfileFullView } from "@/components/profile/profile-full-view"
+
+export function ProfileAnalyticsPanelClient({
+ id,
+ type,
+ userProfilesEnabled = true,
+}: {
+ id: number
+ type: string
+ userProfilesEnabled?: boolean
+}) {
+ return
+}
diff --git a/frontend/components/profile/profile-analytics-panel-view.tsx b/frontend/components/profile/profile-analytics-panel-view.tsx
new file mode 100644
index 000000000..b1424b3e4
--- /dev/null
+++ b/frontend/components/profile/profile-analytics-panel-view.tsx
@@ -0,0 +1,3 @@
+import { ProfileFullView } from "@/components/profile/profile-full-view"
+
+export { ProfileFullView as ProfileAnalyticsPanelView }
diff --git a/frontend/components/profile/profile-analytics-panel.tsx b/frontend/components/profile/profile-analytics-panel.tsx
new file mode 100644
index 000000000..302f15583
--- /dev/null
+++ b/frontend/components/profile/profile-analytics-panel.tsx
@@ -0,0 +1,26 @@
+import { loadProfileAnalyticsAction } from "@/app/profile/actions"
+import { ProfileFullView } from "@/components/profile/profile-full-view"
+
+export async function ProfileAnalyticsPanel({
+ id,
+ type,
+ userProfilesEnabled = true,
+}: {
+ id: number
+ type: string
+ userProfilesEnabled?: boolean
+}) {
+ const result = await loadProfileAnalyticsAction(id, type)
+ if (!result.data) {
+ return Profile analytics unavailable.
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/components/profile/profile-bar-data-section.tsx b/frontend/components/profile/profile-bar-data-section.tsx
new file mode 100644
index 000000000..df63589ee
--- /dev/null
+++ b/frontend/components/profile/profile-bar-data-section.tsx
@@ -0,0 +1,95 @@
+import Link from "next/link"
+import type { ProfileBarItemDto } from "@/lib/profile/types"
+
+function getBarTitle(item: ProfileBarItemDto) {
+ return item.title ?? item.titleOne ?? item.key
+}
+
+function getBarSubtitle(item: ProfileBarItemDto) {
+ return item.titleTwo ?? item.dateTitle ?? item.date ?? item.dateLabel ?? null
+}
+
+export function ProfileBarDataSection({
+ title,
+ items,
+}: {
+ title: string
+ items: ProfileBarItemDto[]
+}) {
+ if (items.length === 0) return null
+
+ const maxCount = Math.max(...items.map((item) => item.count), 1)
+
+ return (
+
+
{title}
+
+ {items.map((item) => {
+ const width = Math.max(4, Math.round((item.count / maxCount) * 100))
+ const label = getBarTitle(item)
+ const subtitle = getBarSubtitle(item)
+
+ return (
+
+
+
+ {item.href ? (
+
+ {label}
+
+ ) : (
+
{label}
+ )}
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+
+
+
{item.count.toLocaleString()} runs
+ {typeof item.percent === "number" ?
{item.percent}%
: null}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export function ProfileBarDataGrid({
+ type,
+ id,
+ users,
+ reports,
+ fails,
+}: {
+ type: string
+ id: number
+ users: ProfileBarItemDto[]
+ reports: ProfileBarItemDto[]
+ fails: ProfileBarItemDto[]
+}) {
+ const showUsers = type !== "user"
+ const showReports =
+ type === "user" || type === "term" || type === "collection" || (type === "report" && id === -1)
+
+ const columnClass =
+ type === "term" || type === "collection" || (type === "report" && id === -1)
+ ? "md:grid-cols-3"
+ : "md:grid-cols-2"
+
+ return (
+
+ {showUsers ?
: null}
+ {showReports ?
: null}
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-date-range-select.tsx b/frontend/components/profile/profile-date-range-select.tsx
new file mode 100644
index 000000000..211a544b3
--- /dev/null
+++ b/frontend/components/profile/profile-date-range-select.tsx
@@ -0,0 +1,37 @@
+"use client"
+
+import type { ProfileDateRangeId } from "@/lib/profile/date-ranges"
+import { cn } from "@/lib/utils"
+
+export function ProfileDateRangeSelect({
+ value,
+ options,
+ onChange,
+ className,
+}: {
+ value: ProfileDateRangeId
+ options: Array<{ id: ProfileDateRangeId; label: string }>
+ onChange: (id: ProfileDateRangeId) => void
+ className?: string
+}) {
+ const active = options.find((option) => option.id === value)
+
+ return (
+
+ Range
+ onChange(event.target.value as ProfileDateRangeId)}
+ className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ aria-label="Profile date range"
+ >
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ {active ? Selected range: {active.label} : null}
+
+ )
+}
diff --git a/frontend/components/profile/profile-filter-sidebar.tsx b/frontend/components/profile/profile-filter-sidebar.tsx
new file mode 100644
index 000000000..c8c54c838
--- /dev/null
+++ b/frontend/components/profile/profile-filter-sidebar.tsx
@@ -0,0 +1,63 @@
+"use client"
+
+import { Filter } from "lucide-react"
+import { ProfileDateRangeSelect } from "@/components/profile/profile-date-range-select"
+import type { ProfileDateRangeId } from "@/lib/profile/date-ranges"
+import { cn } from "@/lib/utils"
+
+export function ProfileFilterSidebar({
+ dateRangeId,
+ dateRangeOptions,
+ onDateRangeChange,
+ className,
+}: {
+ dateRangeId: ProfileDateRangeId
+ dateRangeOptions: Array<{ id: ProfileDateRangeId; label: string }>
+ onDateRangeChange: (id: ProfileDateRangeId) => void
+ className?: string
+}) {
+ return (
+
+
+
+ Filter Profile
+
+
+
+
+ Date range
+
+
+
+ {dateRangeOptions.map((option) => (
+
+ onDateRangeChange(option.id)}
+ className={cn(
+ "w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors",
+ dateRangeId === option.id
+ ? "bg-primary/10 font-medium text-foreground"
+ : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
+ )}
+ >
+ {option.label}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-full-view.tsx b/frontend/components/profile/profile-full-view.tsx
new file mode 100644
index 000000000..d95c8bf76
--- /dev/null
+++ b/frontend/components/profile/profile-full-view.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
+import { loadProfileAnalyticsAction, type ProfileAnalyticsData } from "@/app/profile/actions"
+import { ProfileBarDataGrid } from "@/components/profile/profile-bar-data-section"
+import { ProfileDateRangeSelect } from "@/components/profile/profile-date-range-select"
+import { ProfileFilterSidebar } from "@/components/profile/profile-filter-sidebar"
+import {
+ ProfileHistoryChart,
+ ProfileSummaryStats,
+} from "@/components/profile/profile-history-chart"
+import { ProfileRunListTable } from "@/components/profile/profile-run-list-table"
+import {
+ getProfileTabs,
+ ProfileSectionNav,
+ type ProfileTabId,
+ ProfileTabPanel,
+} from "@/components/profile/profile-section-nav"
+import { ProfileStarsTable } from "@/components/profile/profile-stars-table"
+import { ProfileSubscriptionsTable } from "@/components/profile/profile-subscriptions-table"
+import {
+ DEFAULT_PROFILE_DATE_RANGE_ID,
+ getProfileDateRangeById,
+ getProfileDateRanges,
+ type ProfileDateRangeId,
+} from "@/lib/profile/date-ranges"
+import { cn } from "@/lib/utils"
+
+export function ProfileFullView({
+ id,
+ type,
+ initialData,
+ variant = "embedded",
+ userProfilesEnabled = true,
+}: {
+ id: number
+ type: string
+ initialData?: ProfileAnalyticsData | null
+ variant?: "embedded" | "page"
+ userProfilesEnabled?: boolean
+}) {
+ const dateRangeOptions = useMemo(
+ () => getProfileDateRanges().map((range) => ({ id: range.id, label: range.label })),
+ [],
+ )
+ const tabs = useMemo(() => getProfileTabs(type, id), [id, type])
+
+ const [activeTab, setActiveTab] = useState("runs")
+ const [dateRangeId, setDateRangeId] = useState(DEFAULT_PROFILE_DATE_RANGE_ID)
+ const [data, setData] = useState(initialData ?? null)
+ const [error, setError] = useState(null)
+ const [isPending, startTransition] = useTransition()
+
+ const loadData = useCallback(
+ (nextRangeId: ProfileDateRangeId) => {
+ const range = getProfileDateRangeById(nextRangeId)
+ startTransition(() => {
+ void loadProfileAnalyticsAction(id, type, {
+ start_at: range.start_at,
+ end_at: range.end_at,
+ }).then((result) => {
+ if (!result.data) {
+ setError(result.error ?? "unknown")
+ setData(null)
+ return
+ }
+ setError(null)
+ setData(result.data)
+ })
+ })
+ },
+ [id, type],
+ )
+
+ const skipInitialFetch = useRef(Boolean(initialData))
+
+ useEffect(() => {
+ if (skipInitialFetch.current) {
+ skipInitialFetch.current = false
+ return
+ }
+ loadData(dateRangeId)
+ }, [dateRangeId, loadData])
+
+ const handleDateRangeChange = (nextRangeId: ProfileDateRangeId) => {
+ setDateRangeId(nextRangeId)
+ }
+
+ if (!data && isPending) {
+ return Loading profile...
+ }
+
+ if (!data) {
+ return (
+
+ {error ? "Unable to load profile analytics." : "Profile analytics unavailable."}
+
+ )
+ }
+
+ const chart = data.chart
+ const showTitle = type !== "user" && id !== -1
+
+ return (
+
+ {showTitle ? (
+
Report Activity
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {chart ? (
+
+ ) : null}
+
+
+
+ {chart ? : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-history-chart.tsx b/frontend/components/profile/profile-history-chart.tsx
new file mode 100644
index 000000000..81c03121c
--- /dev/null
+++ b/frontend/components/profile/profile-history-chart.tsx
@@ -0,0 +1,99 @@
+import type { ProfileChartHistoryPoint } from "@/lib/profile/types"
+
+export function ProfileHistoryChart({ history }: { history: ProfileChartHistoryPoint[] }) {
+ if (history.length === 0) {
+ return No history data available.
+ }
+
+ const maxRuns = Math.max(...history.map((point) => point.runs), 1)
+ const maxUsers = Math.max(...history.map((point) => point.users), 1)
+
+ return (
+
+
+
+
+ Runs
+
+
+
+ Users
+
+
+
+ Run time
+
+
+
+
+
+ {history.map((point) => {
+ const runsHeight = Math.max(8, Math.round((point.runs / maxRuns) * 180))
+ const usersHeight = Math.max(8, Math.round((point.users / maxUsers) * 180))
+
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+function ProfileStatCard({
+ label,
+ value,
+ suffix,
+}: {
+ label: string
+ value: number
+ suffix?: string
+}) {
+ return (
+
+
+ {value.toLocaleString()}
+ {suffix ? {suffix} : null}
+
+
{label}
+
+ )
+}
+
+export function ProfileSummaryStats({
+ runs,
+ users,
+ runTime,
+}: {
+ runs: number
+ users: number
+ runTime: number
+}) {
+ return (
+
+ )
+}
diff --git a/frontend/components/profile/profile-run-list-table.tsx b/frontend/components/profile/profile-run-list-table.tsx
new file mode 100644
index 000000000..e13f01f1d
--- /dev/null
+++ b/frontend/components/profile/profile-run-list-table.tsx
@@ -0,0 +1,49 @@
+import Link from "next/link"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import type { ProfileRunListItemDto } from "@/lib/profile/types"
+
+export function ProfileRunListTable({ rows }: { rows: ProfileRunListItemDto[] }) {
+ if (rows.length === 0) {
+ return No run data to show.
+ }
+
+ const showType = rows.some((row) => row.type?.trim())
+
+ return (
+
+
+
+ Name
+ {showType ? Type : null}
+ Runs
+ Last Run
+
+
+
+ {rows.map((row) => (
+
+
+ {row.url ? (
+
+ {row.name}
+
+ ) : (
+ row.name
+ )}
+
+ {showType ? {row.type?.trim() || "—"} : null}
+ {typeof row.runs === "number" ? row.runs : "—"}
+ {row.lastRun?.trim() || "—"}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-section-nav.tsx b/frontend/components/profile/profile-section-nav.tsx
new file mode 100644
index 000000000..42ab820ed
--- /dev/null
+++ b/frontend/components/profile/profile-section-nav.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { cn } from "@/lib/utils"
+
+export type ProfileTabId = "runs" | "report-runs" | "stars" | "subscriptions"
+
+const TAB_LABELS: Record = {
+ runs: "Run Analytics",
+ "report-runs": "Report Runs",
+ stars: "Stars",
+ subscriptions: "Subscriptions",
+}
+
+export function getProfileTabs(type: string, id: number): ProfileTabId[] {
+ const tabs: ProfileTabId[] = ["runs"]
+
+ if (type === "report") {
+ tabs.push("report-runs")
+ }
+
+ if (type !== "user" && id !== -1) {
+ tabs.push("stars")
+ }
+
+ if (type === "report") {
+ tabs.push("subscriptions")
+ }
+
+ return tabs
+}
+
+export function ProfileSectionNav({
+ activeTab,
+ tabs,
+ onTabChange,
+}: {
+ activeTab: ProfileTabId
+ tabs: ProfileTabId[]
+ onTabChange: (tab: ProfileTabId) => void
+}) {
+ if (tabs.length <= 1) return null
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ onTabChange(tab)}
+ className={cn(
+ "rounded-t-md px-3 py-2 font-medium transition-colors",
+ activeTab === tab
+ ? "border border-b-0 border-border/60 bg-background text-foreground"
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+ )}
+ aria-current={activeTab === tab ? "page" : undefined}
+ >
+ {TAB_LABELS[tab]}
+
+
+ ))}
+
+
+ )
+}
+
+export function ProfileTabPanel({
+ tab,
+ activeTab,
+ children,
+}: {
+ tab: ProfileTabId
+ activeTab: ProfileTabId
+ children: ReactNode
+}) {
+ if (tab !== activeTab) return null
+ return
+}
diff --git a/frontend/components/profile/profile-stars-table.tsx b/frontend/components/profile/profile-stars-table.tsx
new file mode 100644
index 000000000..f25b8af99
--- /dev/null
+++ b/frontend/components/profile/profile-stars-table.tsx
@@ -0,0 +1,42 @@
+import Link from "next/link"
+import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"
+import type { ProfileStarUserDto } from "@/lib/profile/types"
+
+export function ProfileStarsTable({
+ rows,
+ userProfilesEnabled = true,
+}: {
+ rows: ProfileStarUserDto[]
+ userProfilesEnabled?: boolean
+}) {
+ if (rows.length === 0) {
+ return There are no stars.
+ }
+
+ const sorted = [...rows].sort((a, b) =>
+ (a.fullName ?? a.email ?? "").localeCompare(b.fullName ?? b.email ?? ""),
+ )
+
+ return (
+
+
+ {sorted.map((user) => {
+ const label = user.fullName ?? user.email ?? `User ${user.id}`
+ return (
+
+
+ {userProfilesEnabled ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/components/profile/profile-subscriptions-table.tsx b/frontend/components/profile/profile-subscriptions-table.tsx
new file mode 100644
index 000000000..844c6e0a0
--- /dev/null
+++ b/frontend/components/profile/profile-subscriptions-table.tsx
@@ -0,0 +1,66 @@
+import Link from "next/link"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import type { ProfileSubscriptionDto } from "@/lib/profile/types"
+
+function formatStatus(value?: string | null) {
+ return value?.replace(/;/g, "; ") ?? "—"
+}
+
+function formatLastRun(value?: string | null) {
+ return value?.trim() || "—"
+}
+
+export function ProfileSubscriptionsTable({
+ rows,
+ userProfilesEnabled = true,
+}: {
+ rows: ProfileSubscriptionDto[]
+ userProfilesEnabled?: boolean
+}) {
+ if (rows.length === 0) {
+ return There are no subscriptions.
+ }
+
+ return (
+
+
+
+ User
+ Subscription Description
+ Last Run
+ Message
+ Subscribed As
+
+
+
+ {rows.map((subscription) => (
+
+
+ {subscription.userId && subscription.userName && userProfilesEnabled ? (
+
+ {subscription.userName}
+
+ ) : (
+ (subscription.userName ?? "N/A")
+ )}
+
+ {subscription.description?.trim() || "—"}
+ {formatLastRun(subscription.lastRunTime)}
+ {formatStatus(subscription.lastStatus)}
+ {formatStatus(subscription.subscriptionTo)}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/components/reports/confirm-link-button.tsx b/frontend/components/reports/confirm-link-button.tsx
new file mode 100644
index 000000000..f9bab2b34
--- /dev/null
+++ b/frontend/components/reports/confirm-link-button.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { AppAlertDialog } from "@/components/ui/app-alert-dialog"
+
+type ConfirmLinkButtonProps = {
+ href: string
+ buttonLabel: string
+ title: string
+ description: string
+ className?: string
+}
+
+export function ConfirmLinkButton({
+ href,
+ buttonLabel,
+ title,
+ description,
+ className,
+}: ConfirmLinkButtonProps) {
+ return (
+ window.open(href, "_blank", "noopener,noreferrer")}
+ />
+ )
+}
diff --git a/frontend/components/reports/report-action-rail.tsx b/frontend/components/reports/report-action-rail.tsx
new file mode 100644
index 000000000..6e1c18312
--- /dev/null
+++ b/frontend/components/reports/report-action-rail.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import { ArrowUpRightFromSquare, CirclePlay, Pencil, Settings } from "lucide-react"
+import type { ReactNode } from "react"
+import {
+ ActionRail,
+ ActionRailGroup,
+ RailExternalLink,
+ RailIconLink,
+ RailTooltipButton,
+} from "@/components/interactions/action-rail"
+import { EntityEngagementRailActions } from "@/components/interactions/entity-engagement-rail-actions"
+import { ReportRunDialog } from "@/components/interactions/report-run-dialog"
+import type { ReportDetail } from "@/lib/reports/types"
+
+function resolveDisabledRunLabel(report: ReportDetail): string {
+ if (report.epicMasterFile === "IDB") {
+ return "Open a related dashboard that uses this."
+ }
+ if (report.editReportUrl) {
+ return "Open in report library."
+ }
+ if (report.epicMasterFile) {
+ return "Run from the Hyperspace report library."
+ }
+ return "Run report unavailable"
+}
+
+function ReportRunRailButton({ report, title }: { report: ReportDetail; title: string }) {
+ const attachments = report.attachments ?? []
+ if (attachments.length > 0) {
+ return
+ }
+
+ if (report.canRun && report.runUrl) {
+ return (
+
+
+ Run report
+
+ )
+ }
+
+ return (
+
+
+ Run report unavailable
+
+ )
+}
+
+export function ReportActionRail({
+ report,
+ title,
+ profilePanel,
+}: {
+ report: ReportDetail
+ title: string
+ profilePanel: ReactNode
+}) {
+ const features = report.features ?? {}
+ const shareUrl = `/reports?id=${report.id}`
+ const hasAdminActions = !!report.editReportUrl || !!report.manageReportUrl
+
+ return (
+
+
+
+
+
+
+
+
+ Open Atlas editor
+
+ ) : null
+ }
+ />
+
+
+ {hasAdminActions && (report.editReportUrl || report.manageReportUrl) ? (
+
+ {report.editReportUrl ? (
+
+
+ Open report editor
+
+ ) : null}
+
+ {report.manageReportUrl ? (
+
+
+ Manage report
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/components/reports/report-edit-form.tsx b/frontend/components/reports/report-edit-form.tsx
new file mode 100644
index 000000000..fb550fcd8
--- /dev/null
+++ b/frontend/components/reports/report-edit-form.tsx
@@ -0,0 +1,344 @@
+"use client"
+
+import Link from "next/link"
+import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
+import {
+ searchReportCollectionsAction,
+ searchReportTermsAction,
+ updateReportAction,
+} from "@/app/reports/actions"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import type { ReportDetail, ReportUpdateBody } from "@/lib/reports/types"
+
+type PickerRow = { id: number; label: string }
+
+function useDebouncedTypeahead(
+ query: string,
+ fetcher: (q: string) => Promise<{ id: number; name: string; description?: string | null }[]>,
+) {
+ const [results, setResults] = useState<
+ { id: number; name: string; description?: string | null }[]
+ >([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (!trimmed) {
+ setResults([])
+ setLoading(false)
+ return
+ }
+ setLoading(true)
+ const handle = window.setTimeout(() => {
+ void fetcher(trimmed)
+ .then(setResults)
+ .catch(() => {
+ setResults([])
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }, 280)
+ return () => {
+ window.clearTimeout(handle)
+ }
+ }, [query, fetcher])
+
+ return { results, loading }
+}
+
+export function ReportEditForm({
+ reportId,
+ initial,
+ cancelHref,
+}: {
+ reportId: number
+ initial: ReportDetail
+ cancelHref: string
+}) {
+ const doc = (initial.document ?? {}) as Record
+
+ const [gitLabProjectUrl, setGitLabProjectUrl] = useState(String(doc.gitLabProjectUrl ?? ""))
+ const [developerDescription, setDeveloperDescription] = useState(
+ String(doc.developerDescription ?? initial.detailedDescription ?? initial.description ?? ""),
+ )
+ const [keyAssumptions, setKeyAssumptions] = useState(String(doc.keyAssumptions ?? ""))
+ const [developerNotes, setDeveloperNotes] = useState(String(doc.developerNotes ?? ""))
+ const [executiveVisibilityYn, setExecutiveVisibilityYn] = useState(
+ String(doc.executiveVisibilityYn ?? "N").toUpperCase() === "Y" ? "Y" : "N",
+ )
+ const [hidden, setHidden] = useState(String(doc.hidden ?? "N").toUpperCase() === "Y" ? "Y" : "N")
+
+ const initialTermRows = useMemo((): PickerRow[] => {
+ return (initial.terms ?? []).map((t) => ({
+ id: t.id,
+ label: t.name?.trim() || `Term ${t.id}`,
+ }))
+ }, [initial.terms])
+
+ const initialCollectionRows = useMemo((): PickerRow[] => {
+ return (initial.collections ?? []).map((c) => ({
+ id: c.id,
+ label: c.name?.trim() || `Collection ${c.id}`,
+ }))
+ }, [initial.collections])
+
+ const [termRows, setTermRows] = useState(initialTermRows)
+ const [collectionRows, setCollectionRows] = useState(initialCollectionRows)
+ const [termQuery, setTermQuery] = useState("")
+ const [collectionQuery, setCollectionQuery] = useState("")
+
+ const termFetcher = useCallback((q: string) => searchReportTermsAction(q), [])
+ const collectionFetcher = useCallback((q: string) => searchReportCollectionsAction(q), [])
+ const termTypeahead = useDebouncedTypeahead(termQuery, termFetcher)
+ const collectionTypeahead = useDebouncedTypeahead(collectionQuery, collectionFetcher)
+
+ const [formError, setFormError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ const imageIds = useMemo(() => (initial.images ?? []).map((img) => img.id), [initial.images])
+
+ const submit = () => {
+ setFormError(null)
+ const body: ReportUpdateBody = {
+ gitLabProjectUrl: gitLabProjectUrl.trim() || null,
+ developerDescription: developerDescription.trim() || null,
+ keyAssumptions: keyAssumptions.trim() || null,
+ developerNotes: developerNotes.trim() || null,
+ executiveVisibilityYn,
+ enabledForHyperspace: "N",
+ doNotPurge: "N",
+ hidden,
+ termIds: termRows.map((r) => r.id),
+ collectionIds: collectionRows.map((r) => r.id),
+ fragilityTagIds: [],
+ imageIds,
+ serviceRequestIds: [],
+ }
+
+ startTransition(() => {
+ void (async () => {
+ const result = await updateReportAction(reportId, body)
+ if (result?.error) {
+ setFormError(result.error)
+ }
+ })()
+ })
+ }
+
+ return (
+
+
+ Edit report documentation
+
+
+
+
+ GitLab project URL
+ {
+ setGitLabProjectUrl(e.target.value)
+ }}
+ />
+
+
+ Developer description
+ {
+ setDeveloperDescription(e.target.value)
+ }}
+ rows={4}
+ className="w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
+ />
+
+
+ Key assumptions
+ {
+ setKeyAssumptions(e.target.value)
+ }}
+ rows={3}
+ className="w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
+ />
+
+
+ Developer notes
+ {
+ setDeveloperNotes(e.target.value)
+ }}
+ rows={3}
+ className="w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
+ />
+
+
+
+ Executive visibility
+ {
+ setExecutiveVisibilityYn(e.target.value)
+ }}
+ className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
+ >
+ No
+ Yes
+
+
+
+ Hidden
+ {
+ setHidden(e.target.value)
+ }}
+ className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
+ >
+ Visible
+ Hidden
+
+
+
+
+
+
+ {
+ setTermRows((rows) => {
+ if (rows.some((r) => r.id === item.id)) return rows
+ return [...rows, { id: item.id, label: item.name }]
+ })
+ setTermQuery("")
+ }}
+ onRemove={(id) => {
+ setTermRows((rows) => rows.filter((r) => r.id !== id))
+ }}
+ />
+ {
+ setCollectionRows((rows) => {
+ if (rows.some((r) => r.id === item.id)) return rows
+ return [...rows, { id: item.id, label: item.name }]
+ })
+ setCollectionQuery("")
+ }}
+ onRemove={(id) => {
+ setCollectionRows((rows) => rows.filter((r) => r.id !== id))
+ }}
+ />
+
+
+ {formError ? {formError}
: null}
+
+
+
+ {pending ? "Saving…" : "Save changes"}
+
+
+ Cancel
+
+
+
+
+ )
+}
+
+function TypeaheadPicker({
+ label,
+ query,
+ onQueryChange,
+ loading,
+ results,
+ rows,
+ onAdd,
+ onRemove,
+}: {
+ label: string
+ query: string
+ onQueryChange: (value: string) => void
+ loading: boolean
+ results: { id: number; name: string; description?: string | null }[]
+ rows: PickerRow[]
+ onAdd: (item: { id: number; name: string }) => void
+ onRemove: (id: number) => void
+}) {
+ return (
+
+
{label}
+
{
+ onQueryChange(e.target.value)
+ }}
+ placeholder={`Search ${label.toLowerCase()}…`}
+ autoComplete="off"
+ />
+ {loading ?
Searching…
: null}
+ {query.trim() && results.length > 0 ? (
+
+ {results.map((item) => (
+
+ {
+ onAdd(item)
+ }}
+ >
+ {item.name}
+
+
+ ))}
+
+ ) : null}
+ {rows.length > 0 ? (
+
+ {rows.map((row) => (
+
+ {row.label}
+ {
+ onRemove(row.id)
+ }}
+ >
+ Remove
+
+
+ ))}
+
+ ) : (
+
None selected.
+ )}
+
+ )
+}
diff --git a/frontend/components/reports/report-image-upload.tsx b/frontend/components/reports/report-image-upload.tsx
new file mode 100644
index 000000000..d38aa6031
--- /dev/null
+++ b/frontend/components/reports/report-image-upload.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+import { useRef, useState, useTransition } from "react"
+import { uploadReportImageAction } from "@/app/reports/actions"
+import { Button } from "@/components/ui/button"
+import { Label } from "@/components/ui/label"
+
+export function ReportImageUpload({ reportId }: { reportId: number }) {
+ const router = useRouter()
+ const inputRef = useRef(null)
+ const [error, setError] = useState(null)
+ const [message, setMessage] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ return (
+
+
Upload image (max 1 MB)
+
+ {error ?
{error}
: null}
+ {message ?
{message}
: null}
+
{
+ const input = inputRef.current
+ if (!input?.files?.[0]) {
+ setError("Choose an image first.")
+ return
+ }
+ setError(null)
+ setMessage(null)
+ const formData = new FormData()
+ formData.append("file", input.files[0])
+ startTransition(() => {
+ void (async () => {
+ const result = await uploadReportImageAction(reportId, formData)
+ if ("error" in result && result.error) {
+ setError(result.error)
+ return
+ }
+ setMessage("Image uploaded.")
+ input.value = ""
+ router.refresh()
+ })()
+ })
+ }}
+ >
+ {pending ? "Uploading…" : "Upload"}
+
+
+ )
+}
diff --git a/frontend/components/snippets/collection-snippet-card.tsx b/frontend/components/snippets/collection-snippet-card.tsx
new file mode 100644
index 000000000..bc2385b91
--- /dev/null
+++ b/frontend/components/snippets/collection-snippet-card.tsx
@@ -0,0 +1,38 @@
+import { EntityCardFooter } from "@/components/interactions/entity-card-footer"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import type { CollectionListItemDto } from "@/lib/collections/types"
+import { truncateText } from "@/lib/text"
+import { SnippetMediaCard } from "./snippet-media-card"
+
+export function CollectionSnippetCard({ collection }: { collection: CollectionListItemDto }) {
+ const href = `/collections?id=${collection.id}`
+ const excerpt = collection.description?.trim()
+ ? truncateText(collection.description)
+ : "Open to view details."
+
+ return (
+
+ {excerpt} read more
+ >
+ }
+ footer={
+ }
+ />
+ }
+ />
+ )
+}
diff --git a/frontend/components/snippets/initiative-snippet-card.tsx b/frontend/components/snippets/initiative-snippet-card.tsx
new file mode 100644
index 000000000..0405c7b93
--- /dev/null
+++ b/frontend/components/snippets/initiative-snippet-card.tsx
@@ -0,0 +1,26 @@
+import type { InitiativeSummaryDto } from "@/lib/collections/types"
+import { truncateText } from "@/lib/text"
+import { SnippetMediaCard } from "./snippet-media-card"
+
+export function InitiativeSnippetCard({ initiative }: { initiative: InitiativeSummaryDto }) {
+ const id = initiative.id
+ const title = initiative.name?.trim() || (id ? `Initiative ${id}` : "Initiative")
+ const href = id ? `/initiatives?id=${id}` : undefined
+ const excerpt = initiative.description?.trim()
+ ? truncateText(initiative.description)
+ : "Open to view details."
+
+ return (
+
+ {excerpt} read more
+ >
+ }
+ />
+ )
+}
diff --git a/frontend/components/snippets/report-snippet-card.tsx b/frontend/components/snippets/report-snippet-card.tsx
new file mode 100644
index 000000000..e7c58def5
--- /dev/null
+++ b/frontend/components/snippets/report-snippet-card.tsx
@@ -0,0 +1,58 @@
+import { EntityCardFooter } from "@/components/interactions/entity-card-footer"
+import { ReportSnippetRunAction } from "@/components/interactions/report-snippet-run-action"
+import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel"
+import type { CollectionFeatureFlagsDto, CollectionReportDto } from "@/lib/collections/types"
+import { truncateText } from "@/lib/text"
+import { SnippetMediaCard } from "./snippet-media-card"
+
+export function ReportSnippetCard({
+ report,
+ features,
+}: {
+ report: CollectionReportDto
+ features?: CollectionFeatureFlagsDto | null
+}) {
+ const title = report.name?.trim() || `Report ${report.id}`
+ const href = `/reports?id=${report.id}`
+ const excerpt = report.description?.trim()
+ ? truncateText(report.description)
+ : "Open to view details."
+ const tags = ["report", ...(report.canRun ? ["can run"] : [])]
+ const canRequestAccess = (report.attachmentCount ?? 0) > 0 || !!report.canRun
+
+ return (
+
+ }
+ excerpt={
+ <>
+ {excerpt} read more
+ >
+ }
+ footer={
+ }
+ />
+ }
+ />
+ )
+}
diff --git a/frontend/components/snippets/snippet-media-card.tsx b/frontend/components/snippets/snippet-media-card.tsx
new file mode 100644
index 000000000..b731b5b1c
--- /dev/null
+++ b/frontend/components/snippets/snippet-media-card.tsx
@@ -0,0 +1,95 @@
+import { Award, ImageIcon } from "lucide-react"
+import Link from "next/link"
+import type { ReactNode } from "react"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+export function SnippetThumbnail({ alt = "Preview" }: { alt?: string }) {
+ return (
+
+
+ {alt}
+
+ )
+}
+
+export function SnippetMediaCard({
+ title,
+ href,
+ tags,
+ excerpt,
+ footer,
+ headerLeading,
+ showCertified = false,
+ variant = "default",
+}: {
+ title: string
+ href?: string
+ tags: string[]
+ excerpt: ReactNode
+ footer?: ReactNode
+ headerLeading?: ReactNode
+ showCertified?: boolean
+ variant?: "default" | "collection"
+}) {
+ const titleNode = href ? (
+
+ {title}
+ {showCertified ? (
+
+ ) : null}
+
+ ) : (
+
+ {title}
+ {showCertified ? (
+
+ ) : null}
+
+ )
+
+ return (
+
+
+
+
+ {headerLeading}
+
{titleNode}
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+
+ {href ? (
+
+ {excerpt}
+
+ ) : (
+ excerpt
+ )}
+
+
+
+ {footer ? {footer} : null}
+
+ )
+}
diff --git a/frontend/components/snippets/term-snippet-card.tsx b/frontend/components/snippets/term-snippet-card.tsx
new file mode 100644
index 000000000..eff9f97e4
--- /dev/null
+++ b/frontend/components/snippets/term-snippet-card.tsx
@@ -0,0 +1,34 @@
+import type { CollectionTermDto } from "@/lib/collections/types"
+import { truncateText } from "@/lib/text"
+import { SnippetMediaCard } from "./snippet-media-card"
+
+function termIdFromDto(term: CollectionTermDto): number | null {
+ if (typeof term.id === "number") return term.id
+ if (typeof term.termId === "number") return term.termId
+ return null
+}
+
+export function TermSnippetCard({ term }: { term: CollectionTermDto }) {
+ const termId = termIdFromDto(term)
+ const title = term.name?.trim() || (termId ? `Term ${termId}` : "Term")
+ const href = termId ? `/terms?id=${termId}` : undefined
+ const excerpt = term.summary?.trim() ? truncateText(term.summary) : "Open to view details."
+
+ return (
+
+ {excerpt} read more
+ >
+ }
+ footer={
+ typeof term.rank === "number" ? (
+ Rank {term.rank}
+ ) : undefined
+ }
+ />
+ )
+}
diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx
new file mode 100644
index 000000000..7d2473b92
--- /dev/null
+++ b/frontend/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+import type * as React from "react"
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children}
+}
diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx
new file mode 100644
index 000000000..876e12f2a
--- /dev/null
+++ b/frontend/components/ui/alert-dialog.tsx
@@ -0,0 +1,181 @@
+"use client"
+
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+import type * as React from "react"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+function AlertDialog({ ...props }: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/frontend/components/ui/app-alert-dialog.tsx b/frontend/components/ui/app-alert-dialog.tsx
new file mode 100644
index 000000000..8da1cafe0
--- /dev/null
+++ b/frontend/components/ui/app-alert-dialog.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+
+type AppAlertDialogProps = {
+ triggerLabel: string
+ title: string
+ description: string
+ confirmLabel?: string
+ cancelLabel?: string
+ intent?: "confirm" | "error"
+ triggerClassName?: string
+ onConfirm?: () => void
+}
+
+export function AppAlertDialog({
+ triggerLabel,
+ title,
+ description,
+ confirmLabel = "Continue",
+ cancelLabel = "Cancel",
+ intent = "confirm",
+ triggerClassName,
+ onConfirm,
+}: AppAlertDialogProps) {
+ return (
+
+
+
+ {triggerLabel}
+
+
+
+
+ {title}
+ {description}
+
+
+ {cancelLabel}
+
+ {confirmLabel}
+
+
+
+
+ )
+}
diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx
new file mode 100644
index 000000000..ad979dfb0
--- /dev/null
+++ b/frontend/components/ui/avatar.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import { Avatar as AvatarPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage }
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx
new file mode 100644
index 000000000..1c4e559a9
--- /dev/null
+++ b/frontend/components/ui/badge.tsx
@@ -0,0 +1,45 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx
new file mode 100644
index 000000000..f8e6e9be1
--- /dev/null
+++ b/frontend/components/ui/button.tsx
@@ -0,0 +1,67 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx
new file mode 100644
index 000000000..01611a715
--- /dev/null
+++ b/frontend/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx
new file mode 100644
index 000000000..527bf7c5f
--- /dev/null
+++ b/frontend/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import { CheckIcon } from "lucide-react"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
+import type * as React from "react"
+import { cn } from "@/lib/utils"
+
+function Checkbox({ className, ...props }: React.ComponentProps
) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx
new file mode 100644
index 000000000..dd121c731
--- /dev/null
+++ b/frontend/components/ui/dialog.tsx
@@ -0,0 +1,144 @@
+"use client"
+
+import { XIcon } from "lucide-react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+import type * as React from "react"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+function Dialog({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogClose({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx
new file mode 100644
index 000000000..1e2685e62
--- /dev/null
+++ b/frontend/components/ui/dropdown-menu.tsx
@@ -0,0 +1,249 @@
+"use client"
+
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+import type * as React from "react"
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+}
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx
new file mode 100644
index 000000000..ea96a9baa
--- /dev/null
+++ b/frontend/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx
new file mode 100644
index 000000000..0b71a14a0
--- /dev/null
+++ b/frontend/components/ui/label.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import { Label as LabelPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx
new file mode 100644
index 000000000..4e7f2fadd
--- /dev/null
+++ b/frontend/components/ui/popover.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { Popover as PopoverPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return
+}
+
+function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/frontend/components/ui/radio-group.tsx b/frontend/components/ui/radio-group.tsx
new file mode 100644
index 000000000..6aa8eff64
--- /dev/null
+++ b/frontend/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
new file mode 100644
index 000000000..29b1a4d9a
--- /dev/null
+++ b/frontend/components/ui/select.tsx
@@ -0,0 +1,184 @@
+"use client"
+
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+import type * as React from "react"
+import { cn } from "@/lib/utils"
+
+function Select({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/components/ui/separator.tsx b/frontend/components/ui/separator.tsx
new file mode 100644
index 000000000..e6921367e
--- /dev/null
+++ b/frontend/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/frontend/components/ui/sheet.tsx b/frontend/components/ui/sheet.tsx
new file mode 100644
index 000000000..4ea4f0f4a
--- /dev/null
+++ b/frontend/components/ui/sheet.tsx
@@ -0,0 +1,129 @@
+"use client"
+
+import { XIcon } from "lucide-react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+import type * as React from "react"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetClose({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+}
diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx
new file mode 100644
index 000000000..0118624f6
--- /dev/null
+++ b/frontend/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/frontend/components/ui/sonner.tsx b/frontend/components/ui/sonner.tsx
new file mode 100644
index 000000000..c7965f834
--- /dev/null
+++ b/frontend/components/ui/sonner.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/frontend/components/ui/switch.tsx b/frontend/components/ui/switch.tsx
new file mode 100644
index 000000000..7070420c4
--- /dev/null
+++ b/frontend/components/ui/switch.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import { Switch as SwitchPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx
new file mode 100644
index 000000000..4f2e5ba4e
--- /dev/null
+++ b/frontend/components/ui/table.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0", className)}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
diff --git a/frontend/components/ui/tabs.tsx b/frontend/components/ui/tabs.tsx
new file mode 100644
index 000000000..4f38e36b9
--- /dev/null
+++ b/frontend/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx
new file mode 100644
index 000000000..deb70f179
--- /dev/null
+++ b/frontend/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx
new file mode 100644
index 000000000..1287fc03c
--- /dev/null
+++ b/frontend/components/ui/tooltip.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+import type * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/frontend/components/users/user-favorite-card.tsx b/frontend/components/users/user-favorite-card.tsx
new file mode 100644
index 000000000..692d660b9
--- /dev/null
+++ b/frontend/components/users/user-favorite-card.tsx
@@ -0,0 +1,158 @@
+import { BadgeCheck, PlayCircle } from "lucide-react"
+import Link from "next/link"
+import { EntityCardFooter } from "@/components/interactions/entity-card-footer"
+import { ReportSnippetRunAction } from "@/components/interactions/report-snippet-run-action"
+import { ProfileAnalyticsPanelClient } from "@/components/profile/profile-analytics-panel-client"
+import { Badge } from "@/components/ui/badge"
+import { truncateText } from "@/lib/text"
+import type { UserFavoriteItem } from "@/lib/users/types"
+
+function resolveProfileType(type?: string | null): string | null {
+ const normalized = type?.toLowerCase()
+ if (
+ normalized === "report" ||
+ normalized === "collection" ||
+ normalized === "group" ||
+ normalized === "user"
+ ) {
+ return normalized
+ }
+ return null
+}
+
+export function UserFavoriteCard({ item }: { item: UserFavoriteItem }) {
+ const title = item.name?.trim() || item.searchString?.trim() || "Favorite"
+ const href = item.url?.trim() || undefined
+ const typeLabel = item.typeLabel?.trim() || item.type?.trim() || "item"
+ const isReport = typeLabel.toLowerCase() === "report"
+ const isCollection = typeLabel.toLowerCase() === "collection"
+ const excerpt = item.description?.trim() || item.bodyText?.trim() || item.secondaryText?.trim()
+ const profileType = resolveProfileType(item.type)
+ const profileId = item.profileTargetId ? Number(item.profileTargetId) : item.itemId
+
+ const card = (
+
+
+
+
+ {isReport && item.itemId ? (
+
+ ) : null}
+
+ {href ? (
+
+ {title}
+
+ ) : (
+ {title}
+ )}
+ {item.isCertified ? (
+
+ ) : null}
+
+
+
+
+ {typeLabel}
+
+ {item.tags
+ .filter((tag) => tag.showInHeader && tag.name)
+ .map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+
+
+
+ {item.thumbnailUrl || item.placeholderImageUrl ? (
+ // biome-ignore lint/performance/noImgElement: backend-provided thumbnails for workspace cards.
+
+ ) : (
+
+ )}
+
+
{excerpt ? truncateText(excerpt) : "Open to view details."}
+ {href ? (
+
+ read more
+
+ ) : null}
+
+
+
+ {isReport && item.itemId ? (
+
+
+ ) : undefined
+ }
+ features={{
+ sharingEnabled: item.canShare ? undefined : false,
+ requestAccessEnabled: item.canRequestAccess ? undefined : false,
+ }}
+ />
+
+ ) : isCollection && item.itemId ? (
+
+
+ ) : undefined
+ }
+ />
+
+ ) : null}
+
+ )
+
+ return card
+}
diff --git a/frontend/components/users/user-groups-table.tsx b/frontend/components/users/user-groups-table.tsx
new file mode 100644
index 000000000..a17635494
--- /dev/null
+++ b/frontend/components/users/user-groups-table.tsx
@@ -0,0 +1,60 @@
+import Link from "next/link"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import type { UserGroup } from "@/lib/users/types"
+
+export function UserGroupsTable({
+ rows,
+ canViewGroups,
+}: {
+ rows: UserGroup[]
+ canViewGroups: boolean
+}) {
+ if (rows.length === 0) {
+ return (
+
+ No groups to show.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Group Name
+ Type
+ Source
+
+
+
+ {rows.map((group) => (
+
+
+ {canViewGroups ? (
+
+ {group.name?.trim() || `Group ${group.id}`}
+
+ ) : (
+ group.name?.trim() || `Group ${group.id}`
+ )}
+
+ {group.type?.trim() || "—"}
+ {group.source?.trim() || "—"}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/components/users/user-history-section.tsx b/frontend/components/users/user-history-section.tsx
new file mode 100644
index 000000000..dc21a66b5
--- /dev/null
+++ b/frontend/components/users/user-history-section.tsx
@@ -0,0 +1,127 @@
+import Link from "next/link"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import type { UserHistoryItem, UserHistorySection } from "@/lib/users/types"
+
+function HistoryLinks({ items }: { items: UserHistoryItem[] }) {
+ if (items.length === 0) return null
+
+ return (
+
+ {items.map((item, index) => (
+
+ {item.url ? (
+
+ {item.name?.trim() || item.url}
+
+ ) : (
+ item.name?.trim() || "Untitled"
+ )}
+ {item.date ? · {item.date} : null}
+
+ ))}
+
+ )
+}
+
+export function UserHistorySectionView({ history }: { history: UserHistorySection }) {
+ const hasEdits =
+ history.reportEdits.length > 0 ||
+ history.initiativeEdits.length > 0 ||
+ history.collectionEdits.length > 0 ||
+ history.termEdits.length > 0
+ const hasAtlasHistory = history.atlasHistory.length > 0
+
+ if (!hasEdits && !hasAtlasHistory) {
+ return (
+
+ No activity to show.
+
+ )
+ }
+
+ return (
+
+ {hasEdits ? (
+
+
+ Recent Edits - Last 30 Days
+
+ {history.reportEdits.length > 0 ? (
+
+
Reports
+
+
+ ) : null}
+ {history.initiativeEdits.length > 0 ? (
+
+
+ Initiatives
+
+
+
+ ) : null}
+ {history.collectionEdits.length > 0 ? (
+
+
+ Collections
+
+
+
+ ) : null}
+ {history.termEdits.length > 0 ? (
+
+
Terms
+
+
+ ) : null}
+
+ ) : null}
+
+ {hasAtlasHistory ? (
+
+
+ Atlas Browsing History - Last 7 Days
+
+
+
+
+
+ Area
+ Page
+ Date
+
+
+
+ {history.atlasHistory.map((item, index) => (
+
+ {item.type?.trim() || "—"}
+
+ {item.url ? (
+
+ {item.url}
+
+ ) : (
+ "—"
+ )}
+
+ {item.date?.trim() || "—"}
+
+ ))}
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/components/users/user-page-tabs.tsx b/frontend/components/users/user-page-tabs.tsx
new file mode 100644
index 000000000..681516530
--- /dev/null
+++ b/frontend/components/users/user-page-tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { useEffect, useMemo, useState } from "react"
+import {
+ getDefaultUserTab,
+ getHashUserTab,
+ getUserTabs,
+ UserSectionNav,
+ type UserTabId,
+ UserTabPanel,
+} from "@/components/users/user-section-nav"
+import type { UserPageTabs as UserPageTabsConfig } from "@/lib/users/types"
+
+export function UserPageTabs({
+ isCurrentUser,
+ tabs,
+ stars,
+ subscriptions,
+ groups,
+ activity,
+ runList,
+ atlasHistory,
+ analytics,
+}: {
+ isCurrentUser: boolean
+ tabs: UserPageTabsConfig
+ stars: ReactNode
+ subscriptions: ReactNode
+ groups: ReactNode
+ activity: ReactNode
+ runList: ReactNode
+ atlasHistory: ReactNode
+ analytics: ReactNode
+}) {
+ const visibleTabs = useMemo(() => getUserTabs({ tabs }), [tabs])
+ const [activeTab, setActiveTab] = useState(() =>
+ getDefaultUserTab(isCurrentUser, visibleTabs),
+ )
+
+ useEffect(() => {
+ const syncFromHash = () => {
+ const hashTab = getHashUserTab(window.location.hash, visibleTabs)
+ if (hashTab) {
+ setActiveTab(hashTab)
+ return
+ }
+ setActiveTab(getDefaultUserTab(isCurrentUser, visibleTabs))
+ }
+
+ syncFromHash()
+ window.addEventListener("hashchange", syncFromHash)
+ return () => window.removeEventListener("hashchange", syncFromHash)
+ }, [isCurrentUser, visibleTabs])
+
+ return (
+
+
+
+ {stars}
+
+
+ {subscriptions}
+
+
+ {groups}
+
+
+ {activity}
+
+
+ {runList}
+
+
+ {atlasHistory}
+
+
+ {analytics}
+
+
+ )
+}
diff --git a/frontend/components/users/user-run-list-panel.tsx b/frontend/components/users/user-run-list-panel.tsx
new file mode 100644
index 000000000..0fe58163a
--- /dev/null
+++ b/frontend/components/users/user-run-list-panel.tsx
@@ -0,0 +1,18 @@
+import { ProfileRunListTable } from "@/components/profile/profile-run-list-table"
+import { getProfileRunList } from "@/lib/profile/api"
+
+export async function UserRunListPanel({
+ userId,
+ reportTypeIds,
+}: {
+ userId: number
+ reportTypeIds: number[]
+}) {
+ const result = await getProfileRunList({
+ id: userId,
+ type: "user",
+ reportType: reportTypeIds.length > 0 ? reportTypeIds : undefined,
+ })
+
+ return
+}
diff --git a/frontend/components/users/user-section-nav.tsx b/frontend/components/users/user-section-nav.tsx
new file mode 100644
index 000000000..7fd1f5ae6
--- /dev/null
+++ b/frontend/components/users/user-section-nav.tsx
@@ -0,0 +1,124 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import type { ReactNode } from "react"
+
+export type UserTabId =
+ | "stars"
+ | "subscriptions"
+ | "groups"
+ | "activity"
+ | "run-list"
+ | "atlas-history"
+ | "analytics"
+
+const TAB_LABELS: Record = {
+ stars: "Stars",
+ subscriptions: "Subscriptions",
+ groups: "Groups",
+ activity: "Run Analytics",
+ "run-list": "Report Runs",
+ "atlas-history": "Atlas History",
+ analytics: "Analytics",
+}
+
+export function getUserTabs(options: {
+ tabs: {
+ starsVisible: boolean
+ subscriptionsVisible: boolean
+ groupsVisible: boolean
+ activityVisible: boolean
+ runListVisible: boolean
+ atlasHistoryVisible: boolean
+ analyticsVisible: boolean
+ }
+}): UserTabId[] {
+ const visible: UserTabId[] = []
+ if (options.tabs.starsVisible) visible.push("stars")
+ if (options.tabs.subscriptionsVisible) visible.push("subscriptions")
+ if (options.tabs.groupsVisible) visible.push("groups")
+ if (options.tabs.activityVisible) visible.push("activity")
+ if (options.tabs.runListVisible) visible.push("run-list")
+ if (options.tabs.atlasHistoryVisible) visible.push("atlas-history")
+ if (options.tabs.analyticsVisible) visible.push("analytics")
+ return visible
+}
+
+export function getDefaultUserTab(isCurrentUser: boolean, tabs: UserTabId[]): UserTabId {
+ if (isCurrentUser && tabs.includes("stars")) return "stars"
+ if (!isCurrentUser && tabs.includes("activity")) return "activity"
+ return tabs[0] ?? "stars"
+}
+
+export function getHashUserTab(hash: string | null, tabs: UserTabId[]): UserTabId | null {
+ const normalized = hash?.replace(/^#/, "")
+ if (!normalized) return null
+ return tabs.find((tab) => tab === normalized) ?? null
+}
+
+export function UserSectionNav({
+ activeTab,
+ tabs,
+ onTabChange,
+}: {
+ activeTab: UserTabId
+ tabs: UserTabId[]
+ onTabChange: (tab: UserTabId) => void
+}) {
+ const [currentHash, setCurrentHash] = useState("")
+
+ useEffect(() => {
+ const syncHash = () => setCurrentHash(window.location.hash.replace(/^#/, ""))
+ syncHash()
+ window.addEventListener("hashchange", syncHash)
+ return () => window.removeEventListener("hashchange", syncHash)
+ }, [])
+
+ return (
+
+
+
+ )
+}
+
+export function UserTabPanel({
+ tab,
+ activeTab,
+ children,
+}: {
+ tab: UserTabId
+ activeTab: UserTabId
+ children: ReactNode
+}) {
+ if (tab !== activeTab) return null
+ return (
+
+ )
+}
diff --git a/frontend/components/users/user-stars-workspace.tsx b/frontend/components/users/user-stars-workspace.tsx
new file mode 100644
index 000000000..679ec1c3a
--- /dev/null
+++ b/frontend/components/users/user-stars-workspace.tsx
@@ -0,0 +1,500 @@
+"use client"
+
+import {
+ ArrowDown,
+ ArrowUp,
+ BookOpen,
+ ChartBar,
+ Folder,
+ FolderOpen,
+ Lightbulb,
+ Plus,
+ Search,
+ Trash2,
+ UserRound,
+ Users,
+ Waypoints,
+} from "lucide-react"
+import { useMemo, useState, useTransition } from "react"
+import {
+ createUserFolderAction,
+ deleteUserFolderAction,
+ reorderUserFavoritesAction,
+ reorderUserFoldersAction,
+ updateUserFavoriteFolderAssignmentAction,
+ updateUserFolderAction,
+} from "@/app/users/actions"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { UserFavoriteCard } from "@/components/users/user-favorite-card"
+import type { UserStars } from "@/lib/users/types"
+import { cn } from "@/lib/utils"
+
+type FolderFilter = "all" | "unsorted" | number
+
+function filterIcon(type: string) {
+ switch (type) {
+ case "report":
+ return
+ case "collection":
+ return
+ case "initiative":
+ return
+ case "term":
+ return
+ case "user":
+ return
+ case "group":
+ return
+ default:
+ return
+ }
+}
+
+export function UserStarsWorkspace({ stars }: { stars: UserStars }) {
+ const [folders, setFolders] = useState(() =>
+ [...stars.folders].sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)),
+ )
+ const [items, setItems] = useState(() =>
+ [...stars.items].sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)),
+ )
+ const [folderFilter, setFolderFilter] = useState("all")
+ const [typeFilter, setTypeFilter] = useState(null)
+ const [textFilter, setTextFilter] = useState("")
+ const [newFolderName, setNewFolderName] = useState("")
+ const [renameFolderId, setRenameFolderId] = useState(null)
+ const [renameFolderName, setRenameFolderName] = useState("")
+ const [isPending, startTransition] = useTransition()
+
+ const quickFilters = useMemo(() => {
+ const filters: Array<{ id: string; label: string }> = []
+ const state = stars.filters
+ if (!state.showQuickFilters) return filters
+ if (state.hasReports) filters.push({ id: "report", label: "Reports" })
+ if (state.hasCollections) filters.push({ id: "collection", label: "Collections" })
+ if (state.hasInitiatives) filters.push({ id: "initiative", label: "Initiatives" })
+ if (state.hasTerms) filters.push({ id: "term", label: "Terms" })
+ if (state.hasUsers) filters.push({ id: "user", label: "Users" })
+ if (state.hasGroups) filters.push({ id: "group", label: "Groups" })
+ if (state.hasSearches) filters.push({ id: "search", label: "Searches" })
+ return filters
+ }, [stars.filters])
+
+ const filteredItems = useMemo(() => {
+ const query = textFilter.trim().toLowerCase()
+ return [...items]
+ .filter((item) => {
+ if (folderFilter === "unsorted") return item.folderId == null
+ if (typeof folderFilter === "number") return item.folderId === folderFilter
+ return true
+ })
+ .filter((item) => {
+ if (!typeFilter) return true
+ return item.type?.toLowerCase() === typeFilter
+ })
+ .filter((item) => {
+ if (!query) return true
+ const haystack = [
+ item.name,
+ item.description,
+ item.searchString,
+ item.typeLabel,
+ item.secondaryText,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase()
+ return haystack.includes(query)
+ })
+ .sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
+ }, [folderFilter, items, textFilter, typeFilter])
+
+ const refreshPage = () => {
+ window.location.reload()
+ }
+
+ const persistFolderOrder = (nextFolders: typeof folders) => {
+ startTransition(async () => {
+ const payload = nextFolders.map((folder, index) => ({
+ folderId: String(folder.id),
+ folderRank: index + 1,
+ }))
+ const result = await reorderUserFoldersAction(stars.userId, stars.isCurrentUser, payload)
+ if (result.ok) refreshPage()
+ })
+ }
+
+ const persistFavoriteOrder = (nextItems: typeof items) => {
+ startTransition(async () => {
+ const payload = nextItems.map((item, index) => ({
+ favoriteId: String(item.starId),
+ favoriteType: item.type ?? "",
+ favoriteRank: index + 1,
+ }))
+ const result = await reorderUserFavoritesAction(stars.userId, stars.isCurrentUser, payload)
+ if (result.ok) refreshPage()
+ })
+ }
+
+ const moveFolder = (folderId: number, direction: -1 | 1) => {
+ const index = folders.findIndex((folder) => folder.id === folderId)
+ const targetIndex = index + direction
+ if (index < 0 || targetIndex < 0 || targetIndex >= folders.length) return
+ const nextFolders = [...folders]
+ const [folder] = nextFolders.splice(index, 1)
+ nextFolders.splice(targetIndex, 0, folder)
+ setFolders(nextFolders)
+ persistFolderOrder(nextFolders)
+ }
+
+ const moveFavorite = (favoriteId: number, direction: -1 | 1) => {
+ const scopedItems = filteredItems
+ const index = scopedItems.findIndex((item) => item.starId === favoriteId)
+ const targetIndex = index + direction
+ if (index < 0 || targetIndex < 0 || targetIndex >= scopedItems.length) return
+
+ const current = scopedItems[index]
+ const target = scopedItems[targetIndex]
+ const sourceIndex = items.findIndex((item) => item.starId === current.starId)
+ const destinationIndex = items.findIndex((item) => item.starId === target.starId)
+ if (sourceIndex < 0 || destinationIndex < 0) return
+
+ const nextItems = [...items]
+ const [moved] = nextItems.splice(sourceIndex, 1)
+ nextItems.splice(destinationIndex, 0, moved)
+ setItems(nextItems)
+ persistFavoriteOrder(nextItems)
+ }
+
+ const assignFavoriteFolder = (favoriteId: number, favoriteType: string, folderId: string) => {
+ startTransition(async () => {
+ const result = await updateUserFavoriteFolderAssignmentAction(stars.userId, stars.isCurrentUser, {
+ favoriteId,
+ favoriteType,
+ folderId: folderId === "unsorted" ? null : Number(folderId),
+ })
+ if (result.ok) refreshPage()
+ })
+ }
+
+ const handleCreateFolder = () => {
+ const name = newFolderName.trim()
+ if (!name) return
+ startTransition(async () => {
+ const result = await createUserFolderAction(stars.userId, stars.isCurrentUser, { name })
+ if (result.ok) refreshPage()
+ })
+ }
+
+ const handleRenameFolder = () => {
+ if (renameFolderId == null) return
+ const name = renameFolderName.trim()
+ if (!name) return
+ startTransition(async () => {
+ const result = await updateUserFolderAction(
+ stars.userId,
+ stars.isCurrentUser,
+ renameFolderId,
+ {
+ name,
+ },
+ )
+ if (result.ok) refreshPage()
+ })
+ }
+
+ const handleDeleteFolder = (folderId: number) => {
+ startTransition(async () => {
+ const result = await deleteUserFolderAction(stars.userId, stars.isCurrentUser, folderId)
+ if (result.ok) refreshPage()
+ })
+ }
+
+ if (stars.items.length === 0 && stars.suggestedReports.length > 0) {
+ return (
+
+
+ You don't have any favorites! Here's some reports you've used.
+
+
+ {stars.suggestedReports.map((report) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (stars.items.length === 0) {
+ return (
+
+ You don't have any favorites! Search to get started.
+
+ )
+ }
+
+ return (
+
+ {quickFilters.length > 0 ? (
+
+
+
+ Quick Filter
+
+
setTextFilter(event.target.value)}
+ placeholder="type to filter..."
+ className="atlas-home-search-shell h-10 max-w-xs bg-white px-3 text-sm shadow-none outline-none"
+ aria-label="Filter starred items"
+ />
+ {quickFilters.map((filter) => (
+
setTypeFilter((current) => (current === filter.id ? null : filter.id))}
+ className="atlas-home-filter-button gap-2 rounded-full px-4 py-2 text-sm"
+ >
+ {filterIcon(filter.id)}
+ {filter.label}
+
+ ))}
+
+ ) : null}
+
+
+
+ setFolderFilter("all")}
+ className={cn(
+ "atlas-home-card relative w-full border border-transparent px-4 py-4 text-left transition-colors",
+ folderFilter === "all"
+ ? "font-bold"
+ : "font-medium hover:bg-[var(--atlas-home-surface-muted)]",
+ )}
+ >
+
+
+ All
+
+
+ {stars.summary.totalCount}
+
+
+
+ {stars.summary.showUnsortedBucket ? (
+ setFolderFilter("unsorted")}
+ className={cn(
+ "atlas-home-card relative w-full border border-transparent px-4 py-4 text-left transition-colors",
+ folderFilter === "unsorted"
+ ? "font-bold"
+ : "font-medium hover:bg-[var(--atlas-home-surface-muted)]",
+ )}
+ >
+
+
+ Unsorted
+
+
+ {stars.summary.unsortedCount}
+
+
+ ) : null}
+
+ {folders.map((folder, index) => (
+
+
setFolderFilter(folder.id)}
+ className={cn(
+ "atlas-home-card relative w-full border border-transparent px-4 py-4 text-left transition-colors",
+ folderFilter === folder.id
+ ? "font-bold"
+ : "font-medium hover:bg-[var(--atlas-home-surface-muted)]",
+ )}
+ >
+
+
+ {folder.name?.trim() || `Folder ${folder.id}`}
+
+
+ {folder.itemCount}
+
+
+ {stars.canEditWorkspace && folder.canManage ? (
+
+
{
+ setRenameFolderId(folder.id)
+ setRenameFolderName(folder.name?.trim() ?? "")
+ }}
+ >
+ Rename
+
+
handleDeleteFolder(folder.id)}
+ >
+
+
+
moveFolder(folder.id, -1)}
+ >
+
+
+
moveFolder(folder.id, 1)}
+ >
+
+
+
+ ) : null}
+
+ ))}
+
+ {stars.canEditWorkspace && stars.permissions.canCreateFolders ? (
+
+
+ New folder
+
+
setNewFolderName(event.target.value)}
+ placeholder="Folder name"
+ />
+
+
+ Save
+
+
+ ) : null}
+
+ {renameFolderId != null ? (
+
+
+ Rename folder
+
+
setRenameFolderName(event.target.value)}
+ />
+
+ Save
+
+
+ ) : null}
+
+
+
+ {filteredItems.length > 0 ? (
+ filteredItems.map((item, index) => (
+
+ {(stars.canEditWorkspace || item.canReorder) && stars.isCurrentUser ? (
+
+ {stars.permissions.canMoveFavoritesToFolders ? (
+
+ assignFavoriteFolder(item.starId, item.type ?? "", value)
+ }
+ >
+
+
+
+
+ Unsorted
+ {folders.map((folder) => (
+
+ {folder.name?.trim() || `Folder ${folder.id}`}
+
+ ))}
+
+
+ ) : null}
+ {stars.permissions.canReorderFavorites ? (
+ <>
+
moveFavorite(item.starId, -1)}
+ >
+
+
+
moveFavorite(item.starId, 1)}
+ >
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+ ))
+ ) : (
+
+ No favorites match the current filters.
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/components/users/user-subscriptions-table.tsx b/frontend/components/users/user-subscriptions-table.tsx
new file mode 100644
index 000000000..62c0d3e83
--- /dev/null
+++ b/frontend/components/users/user-subscriptions-table.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import type { UserSubscription } from "@/lib/users/types"
+
+function formatStatus(value?: string | null) {
+ return value?.trim() || "—"
+}
+
+export function UserSubscriptionsTable({ rows }: { rows: UserSubscription[] }) {
+ if (rows.length === 0) {
+ return (
+
+ No subscriptions to show.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Report
+ Subscription Description
+ Last Run
+ Message
+ Subscribed As
+
+
+
+ {rows.map((subscription, index) => (
+
+
+ {subscription.reportId ? (
+
+ {subscription.name?.trim() || `Report ${subscription.reportId}`}
+
+ ) : (
+ subscription.name?.trim() || "—"
+ )}
+
+ {formatStatus(subscription.description)}
+ {formatStatus(subscription.lastRun)}
+ {formatStatus(subscription.lastStatus)}
+ {formatStatus(subscription.sentTo)}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/docs/superpowers/plans/2026-06-03-homepage-parity.md b/frontend/docs/superpowers/plans/2026-06-03-homepage-parity.md
new file mode 100644
index 000000000..477210b8c
--- /dev/null
+++ b/frontend/docs/superpowers/plans/2026-06-03-homepage-parity.md
@@ -0,0 +1,85 @@
+# Homepage Parity Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rebuild the Next.js homepage so it matches the C# homepage interaction model with lazy in-page tabs for Stars, Subscriptions, Report Runs, and Groups.
+
+**Architecture:** Render a server shell at `/` and lazy-load tab panels through internal API routes. Normalize backend responses into focused panel models and render them with reusable homepage-specific components that mirror the current C# structure.
+
+**Tech Stack:** Next.js App Router, React 19, TypeScript, Vitest, Testing Library, Biome
+
+---
+
+### Task 1: Homepage data contracts
+
+**Files:**
+- Create: `lib/home/types.ts`
+- Create: `lib/home/constants.ts`
+- Test: `lib/home/constants.test.ts`
+
+- [ ] Define homepage tab ids and panel response types.
+- [ ] Add tests that lock the tab order to `stars`, `subscriptions`, `report-runs`, `groups`.
+- [ ] Verify the tests fail before implementation, then pass after adding constants and types.
+
+### Task 2: Homepage tab data fetchers
+
+**Files:**
+- Create: `lib/home/api.ts`
+- Modify: `lib/profile/api.ts`
+- Test: `lib/home/api.test.ts`
+
+- [ ] Add thin server helpers that fetch lazy tab data from the existing backend-facing utilities.
+- [ ] Introduce a new groups fetcher and any small normalization helpers needed for subscriptions and run lists.
+- [ ] Add tests for error and empty-state normalization.
+
+### Task 3: Lazy tab API routes
+
+**Files:**
+- Create: `app/api/home/stars/route.ts`
+- Create: `app/api/home/subscriptions/route.ts`
+- Create: `app/api/home/report-runs/route.ts`
+- Create: `app/api/home/groups/route.ts`
+- Test: `app/api/home/home-routes.test.ts`
+
+- [ ] Write route tests that assert each endpoint returns JSON payloads or auth errors.
+- [ ] Implement the route handlers with the homepage fetchers.
+- [ ] Re-run the route tests until green.
+
+### Task 4: Homepage shell and lazy tab client
+
+**Files:**
+- Create: `components/home/home-shell.tsx`
+- Create: `components/home/home-tabs-client.tsx`
+- Create: `components/home/home-tab-panel.tsx`
+- Modify: `app/page.tsx`
+- Test: `components/home/home-tabs-client.test.tsx`
+
+- [ ] Write a failing client rendering test for default tab selection, loading state, and one-fetch-per-tab caching.
+- [ ] Implement the shell and client tab logic.
+- [ ] Re-run the test and keep the implementation minimal until it passes.
+
+### Task 5: Homepage panel components and styling
+
+**Files:**
+- Create: `components/home/home-stars-panel.tsx`
+- Create: `components/home/home-subscriptions-panel.tsx`
+- Create: `components/home/home-run-list-panel.tsx`
+- Create: `components/home/home-groups-panel.tsx`
+- Modify: `app/globals.css`
+- Test: `components/home/home-panels.test.tsx`
+
+- [ ] Add rendering tests for panel empty states and representative populated states.
+- [ ] Implement the parity-focused panel components and homepage-specific CSS hooks.
+- [ ] Re-run the panel tests until green.
+
+### Task 6: Final verification
+
+**Files:**
+- Modify: `app/page.tsx`
+- Modify: `components/home/*`
+- Modify: `lib/home/*`
+
+- [ ] Run `pnpm test`.
+- [ ] Run `pnpm typecheck`.
+- [ ] Run `pnpm lint`.
+- [ ] Fix any failures and rerun until all three commands succeed.
diff --git a/frontend/docs/superpowers/specs/2026-06-03-homepage-parity-design.md b/frontend/docs/superpowers/specs/2026-06-03-homepage-parity-design.md
new file mode 100644
index 000000000..a9ed76f0e
--- /dev/null
+++ b/frontend/docs/superpowers/specs/2026-06-03-homepage-parity-design.md
@@ -0,0 +1,50 @@
+# Homepage Parity Design
+
+## Goal
+
+Re-implement the Next.js homepage so it follows the current C# homepage structure rather than the existing simplified approximation.
+
+## Scope
+
+- Keep the homepage as a single route at `/`.
+- Keep in-page tab switching for `Stars`, `Subscriptions`, `Report Runs`, and `Groups`.
+- Load tab content lazily after interaction, mirroring the C# `data-ajax` pattern.
+- Use the existing API as the source of truth for authorization and available actions.
+- Match the C# homepage layout, spacing, typography, icon scale, and card structure closely enough that the page reads as the same product.
+
+## Non-Goals
+
+- Re-implement Razor-specific permission checks in Next.js.
+- Change the report run behavior.
+- Rebuild unrelated pages to full parity in the same change.
+
+## Architecture
+
+The homepage becomes a server-rendered shell with a client-side tab controller. The server shell renders the greeting, top navigation, and tab list. Each tab panel is fetched lazily from a dedicated internal Next.js route that composes permission-aware data from the existing backend APIs.
+
+The default `Stars` tab is selected on page load but not pre-expanded with the old simplified UI. Instead, the tab system owns loading and rendering of the panel content. Reusable presentation components render the homepage cards and table panels so that `Stars`, `Subscriptions`, `Report Runs`, and `Groups` remain isolated and easy to evolve.
+
+## Data Flow
+
+1. `app/page.tsx` fetches the current user and renders the homepage shell.
+2. A client tab component switches tabs without route navigation.
+3. On first activation of a tab, the client fetches `/api/home/`.
+4. The route handler calls existing server helpers or new thin wrappers around the C# API.
+5. The client renders the returned JSON into parity-focused panel components.
+
+## Tab Mapping
+
+- `Stars`: new homepage-focused server helper using `/api/reports` for initial favorites fallback, with a structure that supports folder rail and richer cards.
+- `Subscriptions`: use existing profile/user-oriented data fetch support where possible, normalize into a table-like panel.
+- `Report Runs`: use existing profile run list support.
+- `Groups`: add a thin user/groups fetch wrapper against the C# API and render a simple sortable table-style panel.
+
+## Authorization Strategy
+
+Authorization remains API-driven. Next.js should use returned data and returned URLs to decide whether to show links, disabled controls, or empty states. The UI may gate obvious shell actions on auth presence, but it should not duplicate backend permission rules.
+
+## Testing
+
+- Add unit tests for tab metadata and panel normalization helpers.
+- Add rendering tests for the lazy tab shell and loading behavior.
+- Verify the new homepage route compiles, lints, and passes test coverage for the introduced logic.
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
new file mode 100644
index 000000000..a9f8dc0e3
--- /dev/null
+++ b/frontend/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config"
+import nextVitals from "eslint-config-next/core-web-vitals"
+import nextTs from "eslint-config-next/typescript"
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+])
+
+export default eslintConfig
diff --git a/frontend/lib/api-base.ts b/frontend/lib/api-base.ts
new file mode 100644
index 000000000..700cc8281
--- /dev/null
+++ b/frontend/lib/api-base.ts
@@ -0,0 +1,15 @@
+function stripTrailingSlash(value: string): string {
+ return value.replace(/\/+$/, "")
+}
+
+export function getServerApiBase(fallbackOrigin?: string): string {
+ const apiBase = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? fallbackOrigin
+ if (!apiBase) return ""
+ return stripTrailingSlash(apiBase)
+}
+
+export function getPublicApiBase(fallbackOrigin?: string): string {
+ const apiBase = process.env.NEXT_PUBLIC_API_URL ?? fallbackOrigin
+ if (!apiBase) return ""
+ return stripTrailingSlash(apiBase)
+}
diff --git a/frontend/lib/app-error.ts b/frontend/lib/app-error.ts
new file mode 100644
index 000000000..af3268861
--- /dev/null
+++ b/frontend/lib/app-error.ts
@@ -0,0 +1,58 @@
+export type AppErrorCode =
+ | "auth_required"
+ | "forbidden"
+ | "not_found"
+ | "bad_request"
+ | "server_error"
+ | "service_unavailable"
+ | "unknown"
+
+export type AppErrorOptions = {
+ code: AppErrorCode
+ status?: number
+ message?: string
+ cause?: unknown
+}
+
+export class AppError extends Error {
+ public readonly code: AppErrorCode
+ public readonly status?: number
+ public readonly cause?: unknown
+
+ constructor({ code, status, message, cause }: AppErrorOptions) {
+ super(message ?? code)
+ this.name = "AppError"
+ this.code = code
+ this.status = status
+ this.cause = cause
+ }
+}
+
+export function mapHttpStatusToErrorCode(status: number): AppErrorCode {
+ if (status === 400) return "bad_request"
+ if (status === 401) return "auth_required"
+ if (status === 403) return "forbidden"
+ if (status === 404) return "not_found"
+ if (status === 503) return "service_unavailable"
+ if (status >= 500) return "server_error"
+ return "unknown"
+}
+
+export function getUserFriendlyErrorMessage(code: AppErrorCode): string {
+ switch (code) {
+ case "auth_required":
+ return "Your session has expired. Please sign in again."
+ case "forbidden":
+ return "You do not have permission to view this content."
+ case "not_found":
+ return "The requested content is unavailable or does not exist."
+ case "bad_request":
+ return "This request was invalid. Please review your input and try again."
+ case "server_error":
+ return "We hit a server issue while processing your request. Please try again."
+ case "service_unavailable":
+ return "The service is temporarily unavailable. Please try again shortly."
+ default:
+ return "Something went wrong. Please try again."
+ }
+}
diff --git a/frontend/lib/auth/auth-service.ts b/frontend/lib/auth/auth-service.ts
new file mode 100644
index 000000000..0f80368af
--- /dev/null
+++ b/frontend/lib/auth/auth-service.ts
@@ -0,0 +1,72 @@
+// ---------------------------------------------------------------------------
+// Auth service — server‑side helpers for authentication & authorisation
+// ---------------------------------------------------------------------------
+
+import { cookies } from "next/headers"
+import { getServerApiBase } from "@/lib/api-base"
+import { apiFetchJson } from "@/lib/http"
+import type { AuthUser, Permission } from "./types"
+
+const API_URL = getServerApiBase()
+
+// ---- Token helpers -------------------------------------------------------
+
+/** Read the JWT stored in cookies (server context only). */
+export async function getToken(): Promise {
+ const cookieStore = await cookies()
+ return cookieStore.get("atlas_token")?.value ?? null
+}
+
+// ---- User fetching -------------------------------------------------------
+
+/**
+ * Fetch the current user from the C# API.
+ * Returns `null` when the token is missing or the request fails.
+ */
+export async function getCurrentUser(): Promise {
+ const token = await getToken()
+ if (!token) return null
+
+ if (!API_URL) return null
+ const result = await apiFetchJson(`${API_URL}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return null
+ return result.data
+}
+
+// ---- Permission & role checks (server‑side) --------------------------------
+
+/** Check whether the user has a specific permission. */
+export function hasPermission(user: AuthUser, permission: Permission): boolean {
+ // Full admin has every permission
+ if (user.adminEnabled) return true
+ return user.permissions.includes(permission)
+}
+
+/** Check whether the user has ALL of the given permissions. */
+export function hasAllPermissions(user: AuthUser, permissions: Permission[]): boolean {
+ return permissions.every((p) => hasPermission(user, p))
+}
+
+/** Check whether the user has at least ONE of the given permissions. */
+export function hasAnyPermission(user: AuthUser, permissions: Permission[]): boolean {
+ return permissions.some((p) => hasPermission(user, p))
+}
+
+/** Check whether the user has a specific role. */
+export function hasRole(user: AuthUser, role: string): boolean {
+ return user.roles.includes(role)
+}
+
+/** Check whether the user has any of the given roles. */
+export function hasAnyRole(user: AuthUser, roles: string[]): boolean {
+ return roles.some((r) => user.roles.includes(r))
+}
+
+/** Check whether the user is effectively an administrator. */
+export function isAdmin(user: AuthUser): boolean {
+ return user.adminEnabled
+}
diff --git a/frontend/lib/auth/index.ts b/frontend/lib/auth/index.ts
new file mode 100644
index 000000000..5c398638e
--- /dev/null
+++ b/frontend/lib/auth/index.ts
@@ -0,0 +1,4 @@
+// Barrel export for lib/auth
+
+export * from "./auth-service"
+export * from "./types"
diff --git a/frontend/lib/auth/types.ts b/frontend/lib/auth/types.ts
new file mode 100644
index 000000000..ff93400dc
--- /dev/null
+++ b/frontend/lib/auth/types.ts
@@ -0,0 +1,31 @@
+// Auth type definitions — mirrors the /api/auth/me response shape
+
+export const PERMISSIONS = [
+ "Edit Role Permissions",
+ "Manage Report-Object Relationships",
+ "Approve Terms",
+ "Edit Report Documentation",
+ "Create New Terms",
+ "Edit Report-Object Relationships",
+ "Edit Terms",
+ "Show Advanced Search",
+ "Show Report-Object Relationships",
+ "Create Collection",
+ "Edit Collection",
+ "Delete Collection",
+] as const
+
+export type Permission = (typeof PERMISSIONS)[number]
+
+export const KNOWN_ROLES = ["Term Administrator", "Report Writer", "Administrator"] as const
+
+export type KnownRole = (typeof KNOWN_ROLES)[number]
+
+export interface AuthUser {
+ username: string
+ fullname: string
+ userId: string
+ roles: string[]
+ permissions: Permission[]
+ adminEnabled: boolean
+}
diff --git a/frontend/lib/collections/api.ts b/frontend/lib/collections/api.ts
new file mode 100644
index 000000000..269474807
--- /dev/null
+++ b/frontend/lib/collections/api.ts
@@ -0,0 +1,243 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { getUserFriendlyErrorMessage, mapHttpStatusToErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type {
+ CollectionDetailDto,
+ CollectionsListResponseDto,
+ CollectionTypeaheadItemDto,
+ CollectionWriteBody,
+} from "./types"
+
+export type CollectionsListResult = {
+ data: CollectionsListResponseDto | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type CollectionDetailResult = {
+ data: CollectionDetailDto | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type CollectionMutationOk = { ok: true; data: CollectionDetailDto }
+export type CollectionMutationErr = { ok: false; message: string; code: AppErrorCode }
+export type CollectionMutationResult = CollectionMutationOk | CollectionMutationErr
+
+export type CollectionDeleteResult =
+ | { ok: true }
+ | { ok: false; message: string; code: AppErrorCode }
+
+async function parseErrorMessage(res: Response): Promise {
+ const code = mapHttpStatusToErrorCode(res.status)
+ const fallback = getUserFriendlyErrorMessage(code)
+ try {
+ const text = await res.text()
+ if (!text.trim()) return fallback
+ try {
+ const parsed = JSON.parse(text) as { error?: string; message?: string }
+ return parsed.error ?? parsed.message ?? fallback
+ } catch {
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text
+ }
+ } catch {
+ return fallback
+ }
+}
+
+function toErrorCode(status: number): AppErrorCode {
+ return mapHttpStatusToErrorCode(status)
+}
+
+export async function getCollectionsList(page = 1, pageSize = 20): Promise {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const params = new URLSearchParams({
+ page: String(page),
+ pageSize: String(pageSize),
+ })
+ const result = await apiFetchJson(
+ `${apiBase}/api/collections?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ },
+ )
+
+ if (!result.ok) return { data: null, error: result.error.code, status: result.error.status }
+ return { data: result.data, error: null }
+}
+
+export async function getCollectionById(id: number): Promise {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const result = await apiFetchJson(`${apiBase}/api/collections/${id}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return { data: null, error: result.error.code, status: result.error.status }
+ return { data: result.data, error: null }
+}
+
+export async function createCollection(
+ body: CollectionWriteBody,
+): Promise {
+ const token = await getToken()
+ if (!token)
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/collections`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.status === 201) {
+ const data = (await res.json()) as CollectionDetailDto
+ return { ok: true, data }
+ }
+
+ const message = await parseErrorMessage(res)
+ return { ok: false, message, code: toErrorCode(res.status) }
+}
+
+export async function updateCollection(
+ id: number,
+ body: CollectionWriteBody,
+): Promise {
+ const token = await getToken()
+ if (!token)
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/collections/${id}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ const data = (await res.json()) as CollectionDetailDto
+ return { ok: true, data }
+ }
+
+ const message = await parseErrorMessage(res)
+ return { ok: false, message, code: toErrorCode(res.status) }
+}
+
+export async function deleteCollection(id: number): Promise {
+ const token = await getToken()
+ if (!token)
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/collections/${id}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
+ })
+
+ if (res.status === 204) return { ok: true }
+
+ const message = await parseErrorMessage(res)
+ return { ok: false, message, code: toErrorCode(res.status) }
+}
+
+export async function searchCollectionTerms(q: string): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+
+ const token = await getToken()
+ if (!token) return []
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return []
+
+ const params = new URLSearchParams({ q: trimmed })
+ const result = await apiFetchJson(
+ `${apiBase}/api/collections/search/terms?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ },
+ )
+
+ if (!result.ok) return []
+ return Array.isArray(result.data) ? result.data : []
+}
+
+export async function searchCollectionReports(q: string): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+
+ const token = await getToken()
+ if (!token) return []
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return []
+
+ const params = new URLSearchParams({ q: trimmed })
+ const result = await apiFetchJson(
+ `${apiBase}/api/collections/search/reports?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ },
+ )
+
+ if (!result.ok) return []
+ return Array.isArray(result.data) ? result.data : []
+}
diff --git a/frontend/lib/collections/types.ts b/frontend/lib/collections/types.ts
new file mode 100644
index 000000000..5a6dd9433
--- /dev/null
+++ b/frontend/lib/collections/types.ts
@@ -0,0 +1,92 @@
+/** Types aligned with Collections API (CollectionsApiController). */
+
+export interface CollectionListItemDto {
+ id: number
+ name: string
+ description?: string | null
+ isStarred?: boolean
+ starCount?: number
+}
+
+export interface CollectionsListResponseDto {
+ collections: CollectionListItemDto[]
+ total: number
+ page: number
+ pageSize: number
+}
+
+export interface CollectionFeatureFlagsDto {
+ termsEnabled?: boolean
+ feedbackEnabled?: boolean
+ sharingEnabled?: boolean
+ [key: string]: unknown
+}
+
+export interface CollectionUserSummaryDto {
+ id?: number
+ username?: string | null
+ fullName?: string | null
+}
+
+export interface InitiativeSummaryDto {
+ id?: number
+ name?: string | null
+ description?: string | null
+}
+
+export interface CollectionTermDto {
+ id?: number
+ termId?: number
+ name?: string | null
+ summary?: string | null
+ rank?: number
+}
+
+export interface CollectionReportDto {
+ id: number
+ name?: string | null
+ description?: string | null
+ rank?: number
+ isStarred?: boolean
+ starCount?: number
+ canRun?: boolean
+ attachmentCount?: number
+ runUrl?: string | null
+ epicMasterFile?: string | null
+ editReportUrl?: string | null
+}
+
+export interface CollectionDetailDto {
+ id: number
+ name: string
+ description?: string | null
+ purpose?: string | null
+ hidden?: string | null
+ lastModified?: string | null
+ lastModifiedDisplay?: string | null
+ isStarred?: boolean
+ starCount?: number
+ canCreateCollection?: boolean
+ canEditCollection?: boolean
+ canDeleteCollection?: boolean
+ features?: CollectionFeatureFlagsDto | null
+ lastUpdatedBy?: CollectionUserSummaryDto | null
+ initiative?: InitiativeSummaryDto | null
+ terms?: CollectionTermDto[]
+ reports?: CollectionReportDto[]
+}
+
+export interface CollectionWriteBody {
+ name: string
+ description?: string | null
+ purpose?: string | null
+ hidden: string
+ termIds: number[]
+ reportIds: number[]
+}
+
+export interface CollectionTypeaheadItemDto {
+ id: number
+ name: string
+ description?: string | null
+}
diff --git a/frontend/lib/error-reporting.ts b/frontend/lib/error-reporting.ts
new file mode 100644
index 000000000..6b2cb7987
--- /dev/null
+++ b/frontend/lib/error-reporting.ts
@@ -0,0 +1,9 @@
+export type ErrorContext = Record
+
+export function reportError(error: unknown, context?: ErrorContext): void {
+ if (context) {
+ console.error(error, context)
+ return
+ }
+ console.error(error)
+}
diff --git a/frontend/lib/errors.test.ts b/frontend/lib/errors.test.ts
new file mode 100644
index 000000000..48ff04a2e
--- /dev/null
+++ b/frontend/lib/errors.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest"
+import { mapHttpStatusToErrorCode } from "@/lib/errors"
+
+describe("mapHttpStatusToErrorCode", () => {
+ it("maps common statuses", () => {
+ expect(mapHttpStatusToErrorCode(400)).toBe("bad_request")
+ expect(mapHttpStatusToErrorCode(401)).toBe("auth_required")
+ expect(mapHttpStatusToErrorCode(403)).toBe("forbidden")
+ expect(mapHttpStatusToErrorCode(404)).toBe("not_found")
+ expect(mapHttpStatusToErrorCode(503)).toBe("service_unavailable")
+ expect(mapHttpStatusToErrorCode(500)).toBe("server_error")
+ })
+
+ it("maps unknown statuses to unknown", () => {
+ expect(mapHttpStatusToErrorCode(418)).toBe("unknown")
+ })
+})
diff --git a/frontend/lib/errors.ts b/frontend/lib/errors.ts
new file mode 100644
index 000000000..827fc0875
--- /dev/null
+++ b/frontend/lib/errors.ts
@@ -0,0 +1,6 @@
+export {
+ AppError,
+ type AppErrorCode,
+ getUserFriendlyErrorMessage,
+ mapHttpStatusToErrorCode,
+} from "@/lib/app-error"
diff --git a/frontend/lib/groups/api.ts b/frontend/lib/groups/api.ts
new file mode 100644
index 000000000..c71225052
--- /dev/null
+++ b/frontend/lib/groups/api.ts
@@ -0,0 +1,67 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type { GroupDetail, GroupListResponse, GroupReport, GroupUser } from "./types"
+
+export type GroupListResult = {
+ data: GroupListResponse | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type GroupDetailResult = {
+ data: GroupDetail | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type GroupUsersResult = {
+ data: GroupUser[] | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type GroupReportsResult = {
+ data: GroupReport[] | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+async function authorizedGet(path: string): Promise<{
+ data: T | null
+ error: AppErrorCode | null
+ status?: number
+}> {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const result = await apiFetchJson(`${apiBase}${path}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) {
+ return { data: null, error: result.error.code, status: result.error.status }
+ }
+ return { data: result.data, error: null }
+}
+
+export function getGroupsList(): Promise {
+ return authorizedGet("/api/groups")
+}
+
+export function getGroupById(id: number): Promise {
+ return authorizedGet(`/api/groups/${id}`)
+}
+
+export function getGroupUsers(id: number): Promise {
+ return authorizedGet(`/api/groups/${id}/users`)
+}
+
+export function getGroupReports(id: number): Promise {
+ return authorizedGet(`/api/groups/${id}/reports`)
+}
diff --git a/frontend/lib/groups/types.ts b/frontend/lib/groups/types.ts
new file mode 100644
index 000000000..3bd118f04
--- /dev/null
+++ b/frontend/lib/groups/types.ts
@@ -0,0 +1,55 @@
+export type GroupFeatureFlags = {
+ userProfilesEnabled?: boolean
+}
+
+export type GroupPermissions = {
+ canViewGroups?: boolean
+ canViewUserProfiles?: boolean
+ canViewSiteAnalytics?: boolean
+}
+
+export type GroupListItem = {
+ id: number
+ name?: string | null
+ email?: string | null
+ type?: string | null
+ source?: string | null
+ url?: string | null
+}
+
+export type GroupListResponse = {
+ features?: GroupFeatureFlags | null
+ permissions?: GroupPermissions | null
+ items?: GroupListItem[]
+}
+
+export type GroupDetail = {
+ id: number
+ name?: string | null
+ email?: string | null
+ type?: string | null
+ source?: string | null
+ features?: GroupFeatureFlags | null
+ permissions?: GroupPermissions | null
+}
+
+export type GroupUser = {
+ id: number
+ name?: string | null
+ email?: string | null
+ phone?: string | null
+ epicId?: string | null
+ employeeId?: string | null
+ canOpenUserProfile?: boolean
+ url?: string | null
+}
+
+export type GroupReport = {
+ id: number
+ name?: string | null
+ lastUpdated?: string | null
+ subscriptionCount?: number
+ favoriteCount?: number
+ runCount?: number
+ url?: string | null
+}
diff --git a/frontend/lib/home/api.ts b/frontend/lib/home/api.ts
new file mode 100644
index 000000000..36e4e81cb
--- /dev/null
+++ b/frontend/lib/home/api.ts
@@ -0,0 +1,287 @@
+import { getServerApiBase } from "@/lib/api-base"
+import type { AppErrorCode } from "@/lib/app-error"
+import { getToken } from "@/lib/auth"
+import type {
+ HomeGroupsPanel,
+ HomeRunListPanel,
+ HomeStarsPanel,
+ HomeSubscriptionsPanel,
+ HomeTabId,
+ HomeTabsVisibility,
+ HomeUserPageSummary,
+} from "@/lib/home/types"
+import { apiFetchJson } from "@/lib/http"
+import { getProfileRunList } from "@/lib/profile/api"
+
+type HomeResult = {
+ data: T | null
+ error: AppErrorCode | null
+}
+
+type UserPageDto = {
+ user: {
+ id: number
+ username: string
+ fullName: string
+ firstName: string
+ displayName: string
+ }
+ tabs: {
+ starsVisible: boolean
+ subscriptionsVisible: boolean
+ runListVisible: boolean
+ groupsVisible: boolean
+ }
+ defaultReportTypeIds: number[]
+}
+
+type UserStarsDto = {
+ summary: {
+ totalCount: number
+ unsortedCount: number
+ }
+ filters: {
+ hasReports: boolean
+ hasCollections: boolean
+ hasInitiatives: boolean
+ hasTerms: boolean
+ hasUsers: boolean
+ hasGroups: boolean
+ hasSearches: boolean
+ showQuickFilters: boolean
+ }
+ folders: Array<{
+ id: number
+ name: string
+ itemCount: number
+ }>
+ items: Array<{
+ starId: number
+ itemId?: number | null
+ url?: string | null
+ name: string
+ typeLabel?: string | null
+ description?: string | null
+ bodyText?: string | null
+ starCount?: number
+ }>
+ suggestedReports: Array<{
+ id: number
+ name: string
+ description?: string | null
+ url?: string | null
+ type?: string | null
+ }>
+}
+
+type UserSubscriptionDto = {
+ reportId?: number | null
+ name: string
+ description?: string | null
+ lastStatus?: string | null
+ lastRun?: string | null
+ sentTo?: string | null
+}
+
+type UserGroupDto = {
+ id: number
+ name: string
+ type?: string | null
+ source?: string | null
+}
+
+async function authorizedGet(path: string): Promise> {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const result = await apiFetchJson(`${apiBase}${path}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return { data: null, error: result.error.code }
+ return { data: result.data, error: null }
+}
+
+export async function getHomeUserPageSummary(
+ userId: number,
+): Promise> {
+ const result = await authorizedGet(`/api/users/${userId}`)
+ if (!result.data) return { data: null, error: result.error }
+
+ const dto = result.data
+ const visibility: HomeTabsVisibility = {
+ stars: dto.tabs.starsVisible,
+ subscriptions: dto.tabs.subscriptionsVisible,
+ "report-runs": dto.tabs.runListVisible,
+ groups: dto.tabs.groupsVisible,
+ }
+
+ return {
+ data: {
+ userId: dto.user.id,
+ displayName:
+ dto.user.fullName || dto.user.displayName || dto.user.firstName || dto.user.username,
+ defaultReportTypeIds: dto.defaultReportTypeIds ?? [],
+ visibility,
+ },
+ error: null,
+ }
+}
+
+export async function getHomeStarsPanel(userId: number): Promise> {
+ const result = await authorizedGet(`/api/users/${userId}/stars`)
+ if (!result.data) return { data: null, error: result.error }
+
+ const dto = result.data
+ const cards = dto.items.map((item) => ({
+ id: item.itemId ?? item.starId,
+ href: item.url || "#",
+ title: item.name,
+ typeLabel: item.typeLabel || "Item",
+ description: item.bodyText || item.description || "Open to view details.",
+ starCount: item.starCount ?? 0,
+ canOpenDetails: Boolean(item.url),
+ }))
+
+ const fallbackCards =
+ cards.length > 0
+ ? cards
+ : dto.suggestedReports.map((item) => ({
+ id: item.id,
+ href: item.url || "#",
+ title: item.name,
+ typeLabel: item.type || "Report",
+ description: item.description || "Open to view details.",
+ starCount: 0,
+ canOpenDetails: Boolean(item.url),
+ }))
+ const isSuggestionFallback = dto.items.length === 0 && dto.suggestedReports.length > 0
+
+ const filters = [
+ dto.filters.hasReports ? { id: "reports", label: "Reports" } : null,
+ dto.filters.hasCollections ? { id: "collections", label: "Collections" } : null,
+ dto.filters.hasInitiatives ? { id: "initiatives", label: "Initiatives" } : null,
+ dto.filters.hasTerms ? { id: "terms", label: "Terms" } : null,
+ dto.filters.hasUsers ? { id: "users", label: "Users" } : null,
+ dto.filters.hasGroups ? { id: "groups", label: "Groups" } : null,
+ dto.filters.hasSearches ? { id: "searches", label: "Searches" } : null,
+ ].filter(Boolean) as HomeStarsPanel["filters"]
+
+ return {
+ data: {
+ kind: "stars",
+ title: "Stars",
+ emptyMessage: "You don't have any favorites! Search to get started.",
+ isSuggestionFallback,
+ suggestionHeading: isSuggestionFallback
+ ? "You don't have any favorites! Here's some reports you've used."
+ : undefined,
+ folders: [
+ { id: "all", label: "All", count: dto.summary.totalCount },
+ ...(dto.summary.unsortedCount > 0
+ ? [{ id: "unsorted", label: "Unsorted", count: dto.summary.unsortedCount }]
+ : []),
+ ...dto.folders.map((folder) => ({
+ id: String(folder.id),
+ label: folder.name,
+ count: folder.itemCount,
+ })),
+ ],
+ filters,
+ cards: fallbackCards,
+ },
+ error: null,
+ }
+}
+
+export async function getHomeSubscriptionsPanel(
+ userId: number,
+): Promise> {
+ const result = await authorizedGet(`/api/users/${userId}/subscriptions`)
+ if (!result.data) return { data: null, error: result.error }
+
+ return {
+ data: {
+ kind: "subscriptions",
+ title: "Subscriptions",
+ emptyMessage: "No subscriptions to show.",
+ rows: result.data.map((item, index) => ({
+ id: String(item.reportId ?? index),
+ name: item.name,
+ description: item.description || undefined,
+ lastStatus: item.lastStatus || undefined,
+ lastRun: item.lastRun || undefined,
+ sentTo: item.sentTo || undefined,
+ })),
+ },
+ error: null,
+ }
+}
+
+export async function getHomeRunListPanel(
+ userId: number,
+ reportTypeIds: number[],
+): Promise> {
+ const result = await getProfileRunList({
+ id: userId,
+ type: "user",
+ reportType: reportTypeIds,
+ })
+
+ if (!result.data) return { data: null, error: result.error }
+
+ return {
+ data: {
+ kind: "report-runs",
+ title: "Report Runs",
+ emptyMessage: "No report runs to show.",
+ rows: result.data.map((item, index) => ({
+ id: `${item.name}-${index}`,
+ name: item.name,
+ type: item.type || undefined,
+ href: item.url || undefined,
+ runs: item.runs,
+ lastRun: item.lastRun || undefined,
+ })),
+ },
+ error: null,
+ }
+}
+
+export async function getHomeGroupsPanel(userId: number): Promise> {
+ const result = await authorizedGet(`/api/users/${userId}/groups`)
+ if (!result.data) return { data: null, error: result.error }
+
+ return {
+ data: {
+ kind: "groups",
+ title: "Groups",
+ emptyMessage: "No groups to show.",
+ rows: result.data.map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ type: item.type || undefined,
+ source: item.source || undefined,
+ href: `/groups?id=${item.id}`,
+ })),
+ },
+ error: null,
+ }
+}
+
+export async function getHomeTabPanel(userId: number, reportTypeIds: number[], tabId: HomeTabId) {
+ switch (tabId) {
+ case "stars":
+ return getHomeStarsPanel(userId)
+ case "subscriptions":
+ return getHomeSubscriptionsPanel(userId)
+ case "report-runs":
+ return getHomeRunListPanel(userId, reportTypeIds)
+ case "groups":
+ return getHomeGroupsPanel(userId)
+ }
+}
diff --git a/frontend/lib/home/client-api.ts b/frontend/lib/home/client-api.ts
new file mode 100644
index 000000000..72b1b4aa7
--- /dev/null
+++ b/frontend/lib/home/client-api.ts
@@ -0,0 +1,251 @@
+import type {
+ HomeGroupsPanel,
+ HomeRunListPanel,
+ HomeStarsPanel,
+ HomeSubscriptionsPanel,
+ HomeTabId,
+} from "@/lib/home/types"
+
+function normalizeStarsPanel(payload: {
+ summary: { totalCount: number; unsortedCount: number }
+ filters: {
+ hasReports: boolean
+ hasCollections: boolean
+ hasInitiatives: boolean
+ hasTerms: boolean
+ hasUsers: boolean
+ hasGroups: boolean
+ hasSearches: boolean
+ }
+ folders: Array<{ id: number; name: string; itemCount: number }>
+ items: Array<{
+ starId: number
+ itemId?: number | null
+ url?: string | null
+ name: string
+ typeLabel?: string | null
+ description?: string | null
+ bodyText?: string | null
+ placeholderImageUrl?: string | null
+ thumbnailUrl?: string | null
+ fullImageUrl?: string | null
+ isCertified?: boolean
+ isStarred?: boolean
+ starCount?: number
+ canRun?: boolean
+ runUrl?: string | null
+ runDisabledReason?: string | null
+ canEditInEditor?: boolean
+ editUrl?: string | null
+ canManageInEditor?: boolean
+ manageUrl?: string | null
+ canOpenProfile?: boolean
+ canShare?: boolean
+ canRequestAccess?: boolean
+ tags?: Array<{ name: string; slug?: string | null; showInHeader?: boolean }>
+ }>
+ suggestedReports: Array<{
+ id: number
+ name: string
+ description?: string | null
+ url?: string | null
+ type?: string | null
+ }>
+}): HomeStarsPanel {
+ const isSuggestionFallback = payload.items.length === 0 && payload.suggestedReports.length > 0
+ const cards =
+ payload.items.length > 0
+ ? payload.items.map((item) => ({
+ id: item.itemId ?? item.starId,
+ href: item.url || "#",
+ title: item.name,
+ typeLabel: item.typeLabel || "Item",
+ description: item.bodyText || item.description || "Open to view details.",
+ thumbnailUrl: item.thumbnailUrl || undefined,
+ fullImageUrl: item.fullImageUrl || undefined,
+ placeholderImageUrl: item.placeholderImageUrl || undefined,
+ tags:
+ item.tags
+ ?.filter((tag) => tag.showInHeader)
+ .map((tag) => ({
+ name: tag.name,
+ slug: tag.slug || undefined,
+ showInHeader: tag.showInHeader,
+ })) ?? [],
+ isCertified: item.isCertified ?? false,
+ starCount: item.starCount ?? 0,
+ canOpenDetails: Boolean(item.url),
+ isStarred: item.isStarred ?? true,
+ canRun: item.canRun ?? false,
+ runUrl: item.runUrl || undefined,
+ runDisabledReason: item.runDisabledReason || undefined,
+ canEdit: item.canEditInEditor ?? false,
+ editUrl: item.editUrl || undefined,
+ canManage: item.canManageInEditor ?? false,
+ manageUrl: item.manageUrl || undefined,
+ canOpenProfile: item.canOpenProfile ?? false,
+ canShare: item.canShare ?? false,
+ canRequestAccess: item.canRequestAccess ?? false,
+ }))
+ : payload.suggestedReports.map((item) => ({
+ id: item.id,
+ href: item.url || "#",
+ title: item.name,
+ typeLabel: item.type || "Report",
+ description: item.description || "Open to view details.",
+ starCount: 0,
+ canOpenDetails: Boolean(item.url),
+ isStarred: false,
+ }))
+
+ return {
+ kind: "stars",
+ title: "Stars",
+ emptyMessage: "You don't have any favorites! Search to get started.",
+ isSuggestionFallback,
+ suggestionHeading: isSuggestionFallback
+ ? "You don't have any favorites! Here's some reports you've used."
+ : undefined,
+ folders: [
+ { id: "all", label: "All", count: payload.summary.totalCount },
+ ...(payload.summary.unsortedCount > 0
+ ? [{ id: "unsorted", label: "Unsorted", count: payload.summary.unsortedCount }]
+ : []),
+ ...payload.folders.map((folder) => ({
+ id: String(folder.id),
+ label: folder.name,
+ count: folder.itemCount,
+ })),
+ ],
+ filters: [
+ payload.filters.hasReports ? { id: "reports", label: "Reports" } : null,
+ payload.filters.hasCollections ? { id: "collections", label: "Collections" } : null,
+ payload.filters.hasInitiatives ? { id: "initiatives", label: "Initiatives" } : null,
+ payload.filters.hasTerms ? { id: "terms", label: "Terms" } : null,
+ payload.filters.hasUsers ? { id: "users", label: "Users" } : null,
+ payload.filters.hasGroups ? { id: "groups", label: "Groups" } : null,
+ payload.filters.hasSearches ? { id: "searches", label: "Searches" } : null,
+ ].filter(Boolean) as HomeStarsPanel["filters"],
+ cards,
+ }
+}
+
+function isHomeStarsPanel(payload: unknown): payload is HomeStarsPanel {
+ if (!payload || typeof payload !== "object") return false
+ const panel = payload as Partial
+ return (
+ panel.kind === "stars" &&
+ Array.isArray(panel.folders) &&
+ Array.isArray(panel.filters) &&
+ Array.isArray(panel.cards)
+ )
+}
+
+function normalizeSubscriptionsPanel(
+ payload: Array<{
+ reportId?: number | null
+ name: string
+ description?: string | null
+ lastStatus?: string | null
+ lastRun?: string | null
+ sentTo?: string | null
+ }>,
+): HomeSubscriptionsPanel {
+ return {
+ kind: "subscriptions",
+ title: "Subscriptions",
+ emptyMessage: "No subscriptions to show.",
+ rows: payload.map((item, index) => ({
+ id: String(item.reportId ?? index),
+ name: item.name,
+ description: item.description || undefined,
+ lastStatus: item.lastStatus || undefined,
+ lastRun: item.lastRun || undefined,
+ sentTo: item.sentTo || undefined,
+ })),
+ }
+}
+
+function normalizeGroupsPanel(
+ payload: Array<{
+ id: number
+ name: string
+ type?: string | null
+ source?: string | null
+ }>,
+): HomeGroupsPanel {
+ return {
+ kind: "groups",
+ title: "Groups",
+ emptyMessage: "No groups to show.",
+ rows: payload.map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ type: item.type || undefined,
+ source: item.source || undefined,
+ href: `/groups?id=${item.id}`,
+ })),
+ }
+}
+
+function normalizeRunListPanel(
+ payload: Array<{
+ name: string
+ type?: string | null
+ url?: string | null
+ runs?: number
+ lastRun?: string | null
+ }>,
+): HomeRunListPanel {
+ return {
+ kind: "report-runs",
+ title: "Report Runs",
+ emptyMessage: "No run data to show.",
+ rows: payload.map((item, index) => ({
+ id: `${item.name}-${index}`,
+ name: item.name,
+ type: item.type || undefined,
+ href: item.url || undefined,
+ runs: item.runs,
+ lastRun: item.lastRun || undefined,
+ })),
+ }
+}
+
+export async function fetchHomeTabPanel(tabId: HomeTabId) {
+ const response = await fetch(`/api/home/${tabId}`, {
+ credentials: "include",
+ })
+
+ if (!response.ok) {
+ return { ok: false as const, error: `http_${response.status}` }
+ }
+
+ const payload = (await response.json()) as { ok?: boolean; data?: unknown }
+ const panelPayload = payload && "data" in payload ? payload.data : payload
+
+ switch (tabId) {
+ case "stars":
+ return {
+ ok: true as const,
+ data: isHomeStarsPanel(panelPayload)
+ ? panelPayload
+ : normalizeStarsPanel(panelPayload as Parameters[0]),
+ }
+ case "subscriptions":
+ return {
+ ok: true as const,
+ data: normalizeSubscriptionsPanel(panelPayload as Parameters[0]),
+ }
+ case "groups":
+ return {
+ ok: true as const,
+ data: normalizeGroupsPanel(panelPayload as Parameters[0]),
+ }
+ case "report-runs":
+ return {
+ ok: true as const,
+ data: normalizeRunListPanel(panelPayload as Parameters[0]),
+ }
+ }
+}
diff --git a/frontend/lib/home/constants.test.ts b/frontend/lib/home/constants.test.ts
new file mode 100644
index 000000000..18a7a1c8d
--- /dev/null
+++ b/frontend/lib/home/constants.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest"
+import { getHomeTabById, HOME_TABS } from "@/lib/home/constants"
+
+describe("HOME_TABS", () => {
+ it("locks the homepage tabs to the C# order", () => {
+ expect(HOME_TABS.map((tab) => tab.id)).toEqual([
+ "stars",
+ "subscriptions",
+ "report-runs",
+ "groups",
+ ])
+ })
+
+ it("can resolve a known tab by id", () => {
+ expect(getHomeTabById("report-runs")?.label).toBe("Report Runs")
+ })
+
+ it("returns undefined for unknown tabs", () => {
+ expect(getHomeTabById("missing")).toBeUndefined()
+ })
+})
diff --git a/frontend/lib/home/constants.ts b/frontend/lib/home/constants.ts
new file mode 100644
index 000000000..98d0b56e0
--- /dev/null
+++ b/frontend/lib/home/constants.ts
@@ -0,0 +1,16 @@
+import type { HomeTabDefinition, HomeTabId } from "@/lib/home/types"
+
+export const HOME_TABS: HomeTabDefinition[] = [
+ { id: "stars", label: "Stars" },
+ { id: "subscriptions", label: "Subscriptions" },
+ { id: "report-runs", label: "Report Runs" },
+ { id: "groups", label: "Groups" },
+]
+
+export function getHomeTabById(id: string): HomeTabDefinition | undefined {
+ return HOME_TABS.find((tab) => tab.id === id)
+}
+
+export function isHomeTabId(value: string): value is HomeTabId {
+ return HOME_TABS.some((tab) => tab.id === value)
+}
diff --git a/frontend/lib/home/types.ts b/frontend/lib/home/types.ts
new file mode 100644
index 000000000..7cce9e9cc
--- /dev/null
+++ b/frontend/lib/home/types.ts
@@ -0,0 +1,105 @@
+export type HomeTabId = "stars" | "subscriptions" | "report-runs" | "groups"
+
+export type HomeTabDefinition = {
+ id: HomeTabId
+ label: string
+}
+
+export type HomeTabsVisibility = Record
+
+export type HomeUserPageSummary = {
+ userId: number
+ displayName: string
+ defaultReportTypeIds: number[]
+ visibility: HomeTabsVisibility
+}
+
+export type HomeTabRequestContext = {
+ userId: number
+ defaultReportTypeIds: number[]
+}
+
+export type HomePanelStatus = "idle" | "loading" | "ready" | "error"
+
+export type HomePanelBase = {
+ title?: string
+ emptyMessage?: string
+}
+
+export type HomeStarCard = {
+ id: number
+ href: string
+ title: string
+ typeLabel: string
+ description: string
+ thumbnailUrl?: string
+ fullImageUrl?: string
+ placeholderImageUrl?: string
+ tags?: Array<{ name: string; slug?: string; showInHeader?: boolean }>
+ isCertified?: boolean
+ starCount?: number
+ canOpenDetails?: boolean
+ isStarred?: boolean
+ canRun?: boolean
+ runUrl?: string
+ runDisabledReason?: string
+ canEdit?: boolean
+ editUrl?: string
+ canManage?: boolean
+ manageUrl?: string
+ canOpenProfile?: boolean
+ canShare?: boolean
+ canRequestAccess?: boolean
+}
+
+export type HomeStarsPanel = HomePanelBase & {
+ kind: "stars"
+ folders: Array<{ id: string; label: string; count: number }>
+ filters: Array<{ id: string; label: string }>
+ cards: HomeStarCard[]
+ isSuggestionFallback?: boolean
+ suggestionHeading?: string
+}
+
+export type HomeSubscriptionsPanel = HomePanelBase & {
+ kind: "subscriptions"
+ rows: Array<{
+ id: string
+ name: string
+ description?: string
+ lastStatus?: string
+ lastRun?: string
+ sentTo?: string
+ }>
+}
+
+export type HomeRunListPanel = HomePanelBase & {
+ kind: "report-runs"
+ rows: Array<{
+ id: string
+ name: string
+ type?: string
+ href?: string | null
+ runs?: number
+ lastRun?: string | null
+ }>
+}
+
+export type HomeGroupsPanel = HomePanelBase & {
+ kind: "groups"
+ rows: Array<{
+ id: string
+ name: string
+ type?: string
+ source?: string
+ href?: string
+ }>
+}
+
+export type HomePanelData =
+ | HomeStarsPanel
+ | HomeSubscriptionsPanel
+ | HomeRunListPanel
+ | HomeGroupsPanel
+
+export type HomePanelResponse = { ok: true; data: HomePanelData } | { ok: false; error: string }
diff --git a/frontend/lib/http.ts b/frontend/lib/http.ts
new file mode 100644
index 000000000..17e14a1cf
--- /dev/null
+++ b/frontend/lib/http.ts
@@ -0,0 +1,27 @@
+import { AppError, type AppErrorCode, mapHttpStatusToErrorCode } from "@/lib/app-error"
+
+export type Ok = { ok: true; data: T }
+export type Err = { ok: false; error: AppError }
+export type Result = Ok | Err
+
+type ApiFetchOptions = Omit & {
+ headers?: HeadersInit
+}
+
+function toAppError(err: unknown): AppError {
+ if (err instanceof AppError) return err
+ return new AppError({ code: "unknown", cause: err })
+}
+
+export async function apiFetchJson(url: string, init?: ApiFetchOptions): Promise> {
+ try {
+ const res = await fetch(url, init)
+ if (!res.ok) {
+ const code: AppErrorCode = mapHttpStatusToErrorCode(res.status)
+ return { ok: false, error: new AppError({ code, status: res.status }) }
+ }
+ return { ok: true, data: (await res.json()) as T }
+ } catch (err) {
+ return { ok: false, error: toAppError(err) }
+ }
+}
diff --git a/frontend/lib/interactions/api.ts b/frontend/lib/interactions/api.ts
new file mode 100644
index 000000000..dfc7bd41d
--- /dev/null
+++ b/frontend/lib/interactions/api.ts
@@ -0,0 +1,212 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { getUserFriendlyErrorMessage, mapHttpStatusToErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type {
+ AccessRequestRequest,
+ FeedbackRequest,
+ InteractionRecipientDto,
+ ShareMailRequest,
+ ShareMailResponse,
+ StarToggleRequest,
+ StarToggleResponse,
+} from "./types"
+
+export type InteractionMutationOk = { ok: true; data: T }
+export type InteractionMutationErr = { ok: false; message: string; code: AppErrorCode }
+export type InteractionMutationResult = InteractionMutationOk | InteractionMutationErr
+
+async function parseErrorMessage(res: Response): Promise {
+ const fallback = getUserFriendlyErrorMessage(mapHttpStatusToErrorCode(res.status))
+ try {
+ const text = await res.text()
+ if (!text.trim()) return fallback
+ try {
+ const parsed = JSON.parse(text) as { error?: string; message?: string }
+ return parsed.error ?? parsed.message ?? fallback
+ } catch {
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text
+ }
+ } catch {
+ return fallback
+ }
+}
+
+function toErrorCode(status: number): AppErrorCode {
+ return mapHttpStatusToErrorCode(status)
+}
+
+export async function toggleStar(
+ body: StarToggleRequest,
+): Promise> {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/interactions/stars/toggle`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as StarToggleResponse }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
+
+export async function sendShareMail(
+ body: ShareMailRequest,
+): Promise> {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/interactions/share-mail`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as ShareMailResponse }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
+
+export async function submitFeedback(
+ body: FeedbackRequest,
+): Promise> {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/interactions/feedback`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as unknown }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
+
+export async function submitAccessRequest(
+ body: AccessRequestRequest,
+): Promise> {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/interactions/access-request`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as unknown }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
+
+export async function searchInteractionRecipients(
+ q: string,
+ includeGroups = true,
+): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+
+ const token = await getToken()
+ if (!token) return []
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return []
+
+ const params = new URLSearchParams({ q: trimmed, includeGroups: String(includeGroups) })
+ const result = await apiFetchJson(
+ `${apiBase}/api/interactions/search/recipients?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ },
+ )
+
+ if (!result.ok) return []
+ return Array.isArray(result.data) ? result.data : []
+}
diff --git a/frontend/lib/interactions/features.ts b/frontend/lib/interactions/features.ts
new file mode 100644
index 000000000..75807dab2
--- /dev/null
+++ b/frontend/lib/interactions/features.ts
@@ -0,0 +1,4 @@
+/** Match Razor defaults: features are enabled unless explicitly set to false. */
+export function isInteractionFeatureEnabled(flag?: boolean): boolean {
+ return flag !== false
+}
diff --git a/frontend/lib/interactions/types.ts b/frontend/lib/interactions/types.ts
new file mode 100644
index 000000000..f05e16fd3
--- /dev/null
+++ b/frontend/lib/interactions/types.ts
@@ -0,0 +1,54 @@
+export type InteractionEntityType = "report" | "collection"
+
+export interface StarToggleRequest {
+ type: InteractionEntityType
+ id: number
+}
+
+export interface StarToggleResponse {
+ type: InteractionEntityType
+ id: number
+ isStarred: boolean
+ count: number
+}
+
+export interface ShareRecipientInput {
+ userId: number
+ type: "u" | "g"
+}
+
+export interface ShareMailRequest {
+ draftId?: number
+ to: ShareRecipientInput[]
+ subject: string
+ message: string
+ text: string
+ share: boolean
+ shareName: string
+ shareUrl: string
+}
+
+export interface ShareMailResponse {
+ message: string
+ recipientCount: number
+ shareCount: number
+}
+
+export interface FeedbackRequest {
+ reportName: string
+ reportUrl: string
+ description: string
+}
+
+export interface AccessRequestRequest {
+ reportName: string
+ reportUrl: string
+ directorName: string
+}
+
+export interface InteractionRecipientDto {
+ id: number
+ name: string
+ type: string
+ email?: string | null
+}
diff --git a/frontend/lib/profile/api.ts b/frontend/lib/profile/api.ts
new file mode 100644
index 000000000..49bff7874
--- /dev/null
+++ b/frontend/lib/profile/api.ts
@@ -0,0 +1,87 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type {
+ ProfileBarItemDto,
+ ProfileChartResponseDto,
+ ProfileFilters,
+ ProfileRunListItemDto,
+ ProfileStarUserDto,
+ ProfileSubscriptionDto,
+} from "./types"
+
+export type ProfileResult = {
+ data: T | null
+ error: AppErrorCode | null
+}
+
+function buildProfileQuery(filters: ProfileFilters): string {
+ const params = new URLSearchParams()
+ params.set("id", String(filters.id))
+ params.set("type", filters.type)
+ params.set("start_at", String(filters.start_at ?? -31536000))
+ params.set("end_at", String(filters.end_at ?? 0))
+
+ const appendRepeated = (key: string, values?: string[] | number[]) => {
+ if (!values) return
+ for (const value of values) {
+ params.append(key, String(value))
+ }
+ }
+
+ appendRepeated("server", filters.server)
+ appendRepeated("database", filters.database)
+ appendRepeated("masterFile", filters.masterFile)
+ appendRepeated("visible", filters.visible)
+ appendRepeated("certification", filters.certification)
+ appendRepeated("availability", filters.availability)
+ appendRepeated("reportType", filters.reportType)
+
+ return params.toString()
+}
+
+async function fetchProfile(path: string, filters: ProfileFilters): Promise> {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const query = buildProfileQuery(filters)
+ const result = await apiFetchJson(`${apiBase}/api/profile/${path}?${query}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return { data: null, error: result.error.code }
+ return { data: result.data, error: null }
+}
+
+export function getProfileChart(filters: ProfileFilters) {
+ return fetchProfile("chart", filters)
+}
+
+export function getProfileUsers(filters: ProfileFilters) {
+ return fetchProfile("users", filters)
+}
+
+export function getProfileReports(filters: ProfileFilters) {
+ return fetchProfile("reports", filters)
+}
+
+export function getProfileFails(filters: ProfileFilters) {
+ return fetchProfile("fails", filters)
+}
+
+export function getProfileRunList(filters: ProfileFilters) {
+ return fetchProfile("run-list", filters)
+}
+
+export function getProfileStars(filters: ProfileFilters) {
+ return fetchProfile("stars", filters)
+}
+
+export function getProfileSubscriptions(filters: ProfileFilters) {
+ return fetchProfile("subscriptions", filters)
+}
diff --git a/frontend/lib/profile/date-ranges.ts b/frontend/lib/profile/date-ranges.ts
new file mode 100644
index 000000000..d5e2c7705
--- /dev/null
+++ b/frontend/lib/profile/date-ranges.ts
@@ -0,0 +1,124 @@
+export type ProfileDateRangeId =
+ | "yesterday"
+ | "this-week"
+ | "last-7-days"
+ | "this-month"
+ | "last-30-days"
+ | "last-90-days"
+ | "this-year"
+ | "last-12-months"
+ | "all-time"
+
+export type ProfileDateRange = {
+ id: ProfileDateRangeId
+ label: string
+ start_at: number
+ end_at: number
+}
+
+function startOfDay(date: Date): Date {
+ const next = new Date(date)
+ next.setHours(0, 0, 0, 0)
+ return next
+}
+
+function endOfDay(date: Date): Date {
+ const next = new Date(date)
+ next.setHours(23, 59, 59, 999)
+ return next
+}
+
+function startOfWeek(date: Date): Date {
+ const next = startOfDay(date)
+ const day = next.getDay()
+ const diff = day === 0 ? -6 : 1 - day
+ next.setDate(next.getDate() + diff)
+ return next
+}
+
+function endOfWeek(date: Date): Date {
+ const next = startOfWeek(date)
+ next.setDate(next.getDate() + 6)
+ return endOfDay(next)
+}
+
+function startOfMonth(date: Date): Date {
+ return new Date(date.getFullYear(), date.getMonth(), 1)
+}
+
+function endOfMonth(date: Date): Date {
+ return endOfDay(new Date(date.getFullYear(), date.getMonth() + 1, 0))
+}
+
+function startOfYear(date: Date): Date {
+ return new Date(date.getFullYear(), 0, 1)
+}
+
+function endOfYear(date: Date): Date {
+ return endOfDay(new Date(date.getFullYear(), 11, 31))
+}
+
+function addDays(date: Date, days: number): Date {
+ const next = new Date(date)
+ next.setDate(next.getDate() + days)
+ return next
+}
+
+function addYears(date: Date, years: number): Date {
+ const next = new Date(date)
+ next.setFullYear(next.getFullYear() + years)
+ return next
+}
+
+function differenceInSeconds(target: Date, reference: Date): number {
+ return Math.floor((target.getTime() - reference.getTime()) / 1000)
+}
+
+function buildRange(
+ id: ProfileDateRangeId,
+ label: string,
+ start: Date,
+ end: Date,
+ now: Date,
+): ProfileDateRange {
+ return {
+ id,
+ label,
+ start_at: differenceInSeconds(start, now),
+ end_at: differenceInSeconds(end, now),
+ }
+}
+
+export function getProfileDateRanges(now = new Date()): ProfileDateRange[] {
+ return [
+ buildRange(
+ "yesterday",
+ "Yesterday",
+ startOfDay(addDays(now, -1)),
+ endOfDay(addDays(now, -1)),
+ now,
+ ),
+ buildRange("this-week", "This week", startOfWeek(now), endOfWeek(now), now),
+ buildRange("last-7-days", "Last 7 days", startOfDay(addDays(now, -7)), now, now),
+ buildRange("this-month", "This month", startOfMonth(now), endOfMonth(now), now),
+ buildRange("last-30-days", "Last 30 days", startOfDay(addDays(now, -30)), now, now),
+ buildRange("last-90-days", "Last 90 days", startOfDay(addDays(now, -90)), now, now),
+ buildRange("this-year", "This year", startOfYear(now), endOfYear(now), now),
+ buildRange("last-12-months", "Last 12 months", addYears(now, -1), now, now),
+ buildRange("all-time", "All time", addYears(now, -10), now, now),
+ ]
+}
+
+export const DEFAULT_PROFILE_DATE_RANGE_ID: ProfileDateRangeId = "last-12-months"
+
+export function getProfileDateRangeById(
+ id: ProfileDateRangeId,
+ now = new Date(),
+): ProfileDateRange {
+ const ranges = getProfileDateRanges(now)
+ return (
+ ranges.find((range) => range.id === id) ??
+ ranges.find((range) => range.id === DEFAULT_PROFILE_DATE_RANGE_ID) ??
+ ranges[0]
+ )
+}
diff --git a/frontend/lib/profile/types.ts b/frontend/lib/profile/types.ts
new file mode 100644
index 000000000..ea6439e2d
--- /dev/null
+++ b/frontend/lib/profile/types.ts
@@ -0,0 +1,66 @@
+export interface ProfileFilters {
+ id: number
+ type: string
+ start_at?: number
+ end_at?: number
+ server?: string[]
+ database?: string[]
+ masterFile?: string[]
+ visible?: string[]
+ certification?: string[]
+ availability?: string[]
+ reportType?: number[]
+}
+
+export interface ProfileChartHistoryPoint {
+ date: string
+ runs: number
+ users: number
+ runTime: number
+}
+
+export interface ProfileChartResponseDto {
+ runs: number
+ users: number
+ runTime: number
+ history: ProfileChartHistoryPoint[]
+}
+
+export interface ProfileBarItemDto {
+ key: string
+ count: number
+ percent: number
+ href?: string | null
+ title?: string | null
+ titleOne?: string | null
+ titleTwo?: string | null
+ date?: string | null
+ dateTitle?: string | null
+ dateLabel?: string | null
+ [key: string]: unknown
+}
+
+export interface ProfileRunListItemDto {
+ name: string
+ type?: string | null
+ url?: string | null
+ runs?: number
+ lastRun?: string | null
+}
+
+export interface ProfileStarUserDto {
+ id: number
+ fullName?: string | null
+ email?: string | null
+}
+
+export interface ProfileSubscriptionDto {
+ id: number
+ userId?: number
+ userName?: string | null
+ emailList?: string | null
+ description?: string | null
+ lastStatus?: string | null
+ lastRunTime?: string | null
+ subscriptionTo?: string | null
+}
diff --git a/frontend/lib/reports/api.ts b/frontend/lib/reports/api.ts
new file mode 100644
index 000000000..2b390689a
--- /dev/null
+++ b/frontend/lib/reports/api.ts
@@ -0,0 +1,203 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { getUserFriendlyErrorMessage, mapHttpStatusToErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type {
+ MaintenanceStatus,
+ ReportDetail,
+ ReportLookupItem,
+ ReportsListResponse,
+ ReportTypeaheadItem,
+ ReportUpdateBody,
+} from "./types"
+
+export type ReportDetailResult = {
+ data: ReportDetail | null
+ error: AppErrorCode | null
+}
+
+export type ReportsListResult = {
+ data: ReportsListResponse | null
+ error: AppErrorCode | null
+}
+
+export type ReportMutationOk = { ok: true; data: ReportDetail }
+export type ReportMutationErr = { ok: false; message: string; code: AppErrorCode }
+export type ReportMutationResult = ReportMutationOk | ReportMutationErr
+
+async function parseErrorMessage(res: Response): Promise {
+ const fallback = getUserFriendlyErrorMessage(mapHttpStatusToErrorCode(res.status))
+ try {
+ const text = await res.text()
+ if (!text.trim()) return fallback
+ try {
+ const parsed = JSON.parse(text) as { error?: string; message?: string }
+ return parsed.error ?? parsed.message ?? fallback
+ } catch {
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text
+ }
+ } catch {
+ return fallback
+ }
+}
+
+function toErrorCode(status: number): AppErrorCode {
+ return mapHttpStatusToErrorCode(status)
+}
+
+async function authorizedGet(
+ path: string,
+): Promise<{ data: T | null; error: AppErrorCode | null }> {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const result = await apiFetchJson(`${apiBase}${path}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return { data: null, error: result.error.code }
+ return { data: result.data, error: null }
+}
+
+export async function getReportsList(page = 1, pageSize = 20): Promise {
+ const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
+ const result = await authorizedGet(`/api/reports?${params}`)
+ return { data: result.data, error: result.error }
+}
+
+export async function getReportDetailById(id: number): Promise {
+ const result = await authorizedGet(`/api/reports/${id}`)
+ return { data: result.data, error: result.error }
+}
+
+export async function getReportTerms(id: number) {
+ return authorizedGet(`/api/reports/${id}/terms`)
+}
+
+export async function getReportQueries(id: number) {
+ return authorizedGet<{
+ queries?: ReportDetail["queries"]
+ componentQueries?: ReportDetail["componentQueries"]
+ }>(`/api/reports/${id}/queries`)
+}
+
+export async function getReportRelationships(id: number) {
+ return authorizedGet<{
+ parents?: ReportDetail["parents"]
+ children?: ReportDetail["children"]
+ collections?: ReportDetail["collections"]
+ groups?: ReportDetail["groups"]
+ }>(`/api/reports/${id}/relationships`)
+}
+
+export async function getReportMaintenanceStatus(id: number) {
+ return authorizedGet(`/api/reports/${id}/maintenance-status`)
+}
+
+export async function getReportLookups(lookupArea: string) {
+ return authorizedGet(`/api/reports/lookups/${encodeURIComponent(lookupArea)}`)
+}
+
+export async function searchReportTerms(q: string): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+ const params = new URLSearchParams({ q: trimmed })
+ const result = await authorizedGet(`/api/reports/search/terms?${params}`)
+ return Array.isArray(result.data) ? result.data : []
+}
+
+export async function searchReportCollections(q: string): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+ const params = new URLSearchParams({ q: trimmed })
+ const result = await authorizedGet(
+ `/api/reports/search/collections?${params}`,
+ )
+ return Array.isArray(result.data) ? result.data : []
+}
+
+export async function searchReportUsers(q: string): Promise {
+ const trimmed = q.trim()
+ if (!trimmed) return []
+ const params = new URLSearchParams({ q: trimmed })
+ const result = await authorizedGet(`/api/reports/search/users?${params}`)
+ return Array.isArray(result.data) ? result.data : []
+}
+
+export async function updateReport(
+ id: number,
+ body: ReportUpdateBody,
+): Promise {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}/api/reports/${id}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as ReportDetail }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
+
+export async function uploadReportImage(id: number, file: File): Promise {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const formData = new FormData()
+ formData.append("file", file)
+
+ const res = await fetch(`${apiBase}/api/reports/${id}/images`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
+ })
+
+ if (res.ok) {
+ return { ok: true, data: (await res.json()) as ReportDetail }
+ }
+
+ return { ok: false, message: await parseErrorMessage(res), code: toErrorCode(res.status) }
+}
diff --git a/frontend/lib/reports/types.ts b/frontend/lib/reports/types.ts
new file mode 100644
index 000000000..b8aa29b80
--- /dev/null
+++ b/frontend/lib/reports/types.ts
@@ -0,0 +1,193 @@
+export type ReportFeatureFlags = {
+ termsEnabled?: boolean
+ userProfilesEnabled?: boolean
+ feedbackEnabled?: boolean
+ requestAccessEnabled?: boolean
+ sharingEnabled?: boolean
+}
+
+export type PeopleRef = {
+ id: number
+ username: string
+ fullName: string
+ email: string
+}
+
+export type MaintenanceStatus = {
+ isRequired: boolean
+ message?: string | null
+ lastMaintenanceDate?: string | null
+ nextMaintenanceDate?: string | null
+ schedule?: { id: number; name: string } | null
+}
+
+export type ReportAttachment = {
+ id: number
+ name?: string | null
+ path?: string | null
+ source?: string | null
+ type?: string | null
+ creationDate?: string | null
+ runUrl?: string | null
+}
+
+export type ReportDetail = {
+ id: number
+ name: string
+ epicMasterFile?: string | null
+ displayTitle?: string | null
+ displayName?: string | null
+ description?: string | null
+ detailedDescription?: string | null
+ repositoryDescription?: string | null
+ typeName?: string | null
+ typeShortName?: string | null
+ availability?: string | null
+ lastModified?: string | null
+ lastLoadDate?: string | null
+ url?: string
+
+ runs?: number
+
+ // capability flags
+ canRun?: boolean
+ canEditDocumentation?: boolean
+ canViewGroups?: boolean
+ canViewUserProfiles?: boolean
+ isStarred?: boolean
+ starCount?: number
+
+ // derived action URLs
+ runUrl?: string | null
+ editReportUrl?: string | null
+ manageReportUrl?: string | null
+ recordViewerUrl?: string | null
+
+ // feature flags
+ features?: ReportFeatureFlags
+
+ // header tags
+ headerTags?: Array<{
+ id: number
+ name?: string | null
+ description?: string | null
+ priority?: number | null
+ showInHeader?: string | boolean | null
+ }>
+
+ // maintenance
+ maintenanceStatus?: MaintenanceStatus | null
+
+ // people
+ author?: PeopleRef | null
+ lastModifiedBy?: PeopleRef | null
+ requester?: PeopleRef | null
+
+ // collections / groups / hierarchy
+ groups?: Array<{ id: number; name?: string | null; email?: string | null; type?: string | null }>
+ collections?: Array<{ id: number; name?: string | null; rank?: number | null }>
+ children?: Array<{
+ id: number
+ name?: string | null
+ displayTitle?: string | null
+ type?: string | null
+ url?: string | null
+ lastModified?: string | null
+ }>
+ parents?: Array<{
+ id: number
+ name?: string | null
+ displayTitle?: string | null
+ type?: string | null
+ url?: string | null
+ lastModified?: string | null
+ }>
+
+ // document-related
+ attachments?: ReportAttachment[]
+ images?: Array<{ id: number; ordinal?: number | null; source?: string | null }>
+
+ // queries / terms
+ queries?: Array<{
+ id: number
+ name?: string | null
+ language?: string | null
+ sourceServer?: string | null
+ source?: string | null
+ }>
+ componentQueries?: Array<{
+ id: number
+ name?: string | null
+ language?: string | null
+ sourceServer?: string | null
+ source?: string | null
+ }>
+ terms?: Array<{ id: number; name?: string | null; summary?: string | null }>
+
+ // full document payload (not rendered yet)
+ document?: unknown
+}
+
+export type ReportListItem = {
+ id: number
+ name: string
+ displayTitle?: string | null
+ displayName?: string | null
+ description?: string | null
+ typeShortName?: string | null
+ lastModified?: string | null
+}
+
+export type ReportsListResponse = {
+ reports: ReportListItem[]
+ total: number
+ page: number
+ pageSize: number
+}
+
+export type ReportLookupItem = {
+ id: number
+ name?: string | null
+ label?: string | null
+}
+
+export type ReportTypeaheadItem = {
+ id: number
+ name: string
+ description?: string | null
+}
+
+export type ReportNewServiceRequest = {
+ ticketNumber: string
+ description: string
+ ticketUrl?: string | null
+}
+
+export type ReportNewMaintenanceLog = {
+ maintenanceLogStatusId: number
+ comment: string
+}
+
+export type ReportUpdateBody = {
+ gitLabProjectUrl?: string | null
+ developerDescription?: string | null
+ keyAssumptions?: string | null
+ developerNotes?: string | null
+ operationalOwnerUserId?: number | null
+ requesterUserId?: number | null
+ organizationalValueId?: number | null
+ estimatedRunFrequencyId?: number | null
+ fragilityId?: number | null
+ maintenanceScheduleId?: number | null
+ executiveVisibilityYn?: string | null
+ enabledForHyperspace?: string | null
+ doNotPurge?: string | null
+ hidden?: string | null
+ termIds: number[]
+ collectionIds: number[]
+ fragilityTagIds: number[]
+ imageIds: number[]
+ serviceRequestIds: number[]
+ newServiceRequest?: ReportNewServiceRequest | null
+ newMaintenanceLog?: ReportNewMaintenanceLog | null
+}
diff --git a/frontend/lib/search/api.ts b/frontend/lib/search/api.ts
new file mode 100644
index 000000000..485fba88b
--- /dev/null
+++ b/frontend/lib/search/api.ts
@@ -0,0 +1,51 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type { SearchResponseDto } from "./types"
+
+type SearchQueryValue = string | string[] | undefined
+
+export type SearchParamsInput = Record
+
+export type SearchResponseResult = {
+ data: SearchResponseDto | null
+ error: AppErrorCode | null
+}
+
+function appendQueryValue(params: URLSearchParams, key: string, value: SearchQueryValue) {
+ if (typeof value === "string") {
+ if (value.trim() !== "") params.append(key, value)
+ return
+ }
+
+ if (Array.isArray(value)) {
+ value
+ .filter((v) => v.trim() !== "")
+ .forEach((v) => {
+ params.append(key, v)
+ })
+ }
+}
+
+export async function searchLibrary(paramsInput: SearchParamsInput): Promise {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const query = new URLSearchParams()
+ Object.entries(paramsInput).forEach(([key, value]) => {
+ appendQueryValue(query, key, value)
+ })
+
+ const url = `${apiBase}/api/search${query.toString() ? `?${query.toString()}` : ""}`
+ const result = await apiFetchJson(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) return { data: null, error: result.error.code }
+ return { data: result.data, error: null }
+}
diff --git a/frontend/lib/search/types.ts b/frontend/lib/search/types.ts
new file mode 100644
index 000000000..46c498959
--- /dev/null
+++ b/frontend/lib/search/types.ts
@@ -0,0 +1,69 @@
+export const SEARCH_TYPES = [
+ "query",
+ "reports",
+ "terms",
+ "collections",
+ "initiatives",
+ "users",
+ "groups",
+] as const
+
+export type SearchType = (typeof SEARCH_TYPES)[number]
+
+export interface SearchResultDto {
+ id: string
+ atlasId: number
+ type: "reports" | "collections" | "terms" | "initiatives" | "users" | "groups" | "external"
+ name: string
+ description: string | null
+ url: string | null
+ reportType: string | null
+ email: string | null
+ epicMasterFile: string | null
+ epicRecordId: string | null
+ epicTemplateId: string | null
+ reportServerPath: string | null
+ executiveVisibility: string | null
+ sourceServer: string | null
+ groupType: string | null
+ isStarred: boolean | null
+ certifications: string[]
+ documented: string | null
+}
+
+export interface FacetValueDto {
+ value: string
+ count: number
+}
+
+export interface FacetDto {
+ key: string
+ values: FacetValueDto[]
+}
+
+export interface HighlightFieldDto {
+ field: string
+ snippet: string
+}
+
+export interface HighlightDto {
+ id: string
+ fields: HighlightFieldDto[]
+}
+
+export interface FilterFieldDto {
+ key: string
+ label: string
+}
+
+export interface SearchResponseDto {
+ results: SearchResultDto[]
+ facets: FacetDto[]
+ highlights: HighlightDto[]
+ filterFields: FilterFieldDto[]
+ total: number
+ page: number
+ pageSize: number
+ qTime: number
+ isAdvancedSearch: boolean
+}
diff --git a/frontend/lib/text.ts b/frontend/lib/text.ts
new file mode 100644
index 000000000..dd09ccc2a
--- /dev/null
+++ b/frontend/lib/text.ts
@@ -0,0 +1,5 @@
+export function truncateText(text: string, maxLength = 160): string {
+ const trimmed = text.trim()
+ if (trimmed.length <= maxLength) return trimmed
+ return `${trimmed.slice(0, maxLength)}…`
+}
diff --git a/frontend/lib/users/api.test.ts b/frontend/lib/users/api.test.ts
new file mode 100644
index 000000000..4729a9d8d
--- /dev/null
+++ b/frontend/lib/users/api.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, describe, expect, test, vi } from "vitest"
+
+vi.mock("@/lib/auth", () => ({
+ getToken: vi.fn(async () => "test-token"),
+}))
+
+vi.mock("@/lib/api-base", () => ({
+ getServerApiBase: vi.fn(() => "https://api.example.test"),
+}))
+
+import { createUserFolder } from "./api"
+
+describe("createUserFolder", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ global.fetch = vi.fn(async () =>
+ new Response(JSON.stringify({ id: 1, name: "Favorites" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ ) as typeof fetch
+ })
+
+ test("uses the current-user folders endpoint when isCurrentUser is true", async () => {
+ await createUserFolder(42, true, { name: "Favorites" })
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.example.test/api/users/me/folders",
+ expect.objectContaining({ method: "POST" }),
+ )
+ })
+
+ test("uses the target-user folders endpoint when isCurrentUser is false", async () => {
+ await createUserFolder(42, false, { name: "Favorites" })
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.example.test/api/users/42/folders",
+ expect.objectContaining({ method: "POST" }),
+ )
+ })
+})
diff --git a/frontend/lib/users/api.ts b/frontend/lib/users/api.ts
new file mode 100644
index 000000000..13fd25ac8
--- /dev/null
+++ b/frontend/lib/users/api.ts
@@ -0,0 +1,245 @@
+import { getServerApiBase } from "@/lib/api-base"
+import { getToken } from "@/lib/auth"
+import type { AppErrorCode } from "@/lib/errors"
+import { getUserFriendlyErrorMessage, mapHttpStatusToErrorCode } from "@/lib/errors"
+import { apiFetchJson } from "@/lib/http"
+import type {
+ CreateUserFavoriteFolderRequest,
+ ReorderUserFavoriteFolderItem,
+ ReorderUserFavoriteItem,
+ ToggleAdminModeResponse,
+ ToggleUserFavoriteRequest,
+ ToggleUserFavoriteResponse,
+ UpdateUserFavoriteFolderAssignmentRequest,
+ UpdateUserFavoriteFolderRequest,
+ UserFavoriteFolder,
+ UserGroup,
+ UserHistorySection,
+ UserPage,
+ UserSearchHistoryItem,
+ UserSharedObjects,
+ UserStars,
+ UserSubscription,
+} from "./types"
+
+export type UsersReadResult = {
+ data: T | null
+ error: AppErrorCode | null
+ status?: number
+}
+
+export type UsersMutationOk = { ok: true; data: T }
+export type UsersMutationErr = { ok: false; message: string; code: AppErrorCode }
+export type UsersMutationResult = UsersMutationOk | UsersMutationErr
+
+async function authorizedGet(path: string): Promise> {
+ const token = await getToken()
+ if (!token) return { data: null, error: "auth_required" }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) return { data: null, error: "service_unavailable" }
+
+ const result = await apiFetchJson(`${apiBase}${path}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ })
+
+ if (!result.ok) {
+ return { data: null, error: result.error.code, status: result.error.status }
+ }
+ return { data: result.data, error: null }
+}
+
+async function parseErrorMessage(res: Response): Promise {
+ const fallback = getUserFriendlyErrorMessage(mapHttpStatusToErrorCode(res.status))
+ try {
+ const text = await res.text()
+ if (!text.trim()) return fallback
+ try {
+ const parsed = JSON.parse(text) as { error?: string; message?: string }
+ return parsed.error ?? parsed.message ?? fallback
+ } catch {
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text
+ }
+ } catch {
+ return fallback
+ }
+}
+
+async function authorizedMutation(
+ path: string,
+ init: RequestInit,
+): Promise> {
+ const token = await getToken()
+ if (!token) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("auth_required"),
+ code: "auth_required",
+ }
+ }
+
+ const apiBase = getServerApiBase()
+ if (!apiBase) {
+ return {
+ ok: false,
+ message: getUserFriendlyErrorMessage("service_unavailable"),
+ code: "service_unavailable",
+ }
+ }
+
+ const res = await fetch(`${apiBase}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ ...(init.headers ?? {}),
+ },
+ cache: "no-store",
+ })
+
+ if (res.ok) {
+ if (res.status === 204) {
+ return { ok: true, data: undefined as T }
+ }
+ const text = await res.text()
+ if (!text.trim()) {
+ return { ok: true, data: undefined as T }
+ }
+ return { ok: true, data: JSON.parse(text) as T }
+ }
+
+ return {
+ ok: false,
+ message: await parseErrorMessage(res),
+ code: mapHttpStatusToErrorCode(res.status),
+ }
+}
+
+export function getUserPage(id: number) {
+ return authorizedGet(`/api/users/${id}`)
+}
+
+export function getUserStars(id: number) {
+ return authorizedGet(`/api/users/${id}/stars`)
+}
+
+export function getUserGroups(id: number) {
+ return authorizedGet(`/api/users/${id}/groups`)
+}
+
+export function getUserSubscriptions(id: number) {
+ return authorizedGet(`/api/users/${id}/subscriptions`)
+}
+
+export function getUserHistory(id: number) {
+ return authorizedGet(`/api/users/${id}/history`)
+}
+
+export function getUserSharedObjects() {
+ return authorizedGet("/api/users/me/shared-objects")
+}
+
+export function getUserSearchHistory() {
+ return authorizedGet("/api/users/me/search-history")
+}
+
+export function createUserFolder(
+ userId: number,
+ isCurrentUser: boolean,
+ body: CreateUserFavoriteFolderRequest,
+) {
+ const path = isCurrentUser ? "/api/users/me/folders" : `/api/users/${userId}/folders`
+ return authorizedMutation(path, {
+ method: "POST",
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateUserFolder(
+ userId: number,
+ isCurrentUser: boolean,
+ folderId: number,
+ body: UpdateUserFavoriteFolderRequest,
+) {
+ const path = isCurrentUser
+ ? `/api/users/me/folders/${folderId}`
+ : `/api/users/${userId}/folders/${folderId}`
+ return authorizedMutation(path, {
+ method: "PUT",
+ body: JSON.stringify(body),
+ })
+}
+
+export function deleteUserFolder(userId: number, isCurrentUser: boolean, folderId: number) {
+ const path = isCurrentUser
+ ? `/api/users/me/folders/${folderId}`
+ : `/api/users/${userId}/folders/${folderId}`
+ return authorizedMutation(path, { method: "DELETE" })
+}
+
+export function reorderUserFolders(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ReorderUserFavoriteFolderItem[],
+) {
+ const path = isCurrentUser
+ ? "/api/users/me/folders/reorder"
+ : `/api/users/${userId}/folders/reorder`
+ return authorizedMutation(path, {
+ method: "POST",
+ body: JSON.stringify(body),
+ })
+}
+
+export function reorderUserFavorites(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ReorderUserFavoriteItem[],
+) {
+ const path = isCurrentUser
+ ? "/api/users/me/favorites/reorder"
+ : `/api/users/${userId}/favorites/reorder`
+ return authorizedMutation(path, {
+ method: "POST",
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateUserFavoriteFolderAssignment(
+ userId: number,
+ isCurrentUser: boolean,
+ body: UpdateUserFavoriteFolderAssignmentRequest,
+) {
+ const path = isCurrentUser
+ ? "/api/users/me/favorites/folder"
+ : `/api/users/${userId}/favorites/folder`
+ return authorizedMutation(path, {
+ method: "PUT",
+ body: JSON.stringify(body),
+ })
+}
+
+export function toggleUserFavorite(
+ userId: number,
+ isCurrentUser: boolean,
+ body: ToggleUserFavoriteRequest,
+) {
+ const path = isCurrentUser
+ ? "/api/users/me/favorites/toggle"
+ : `/api/users/${userId}/favorites/toggle`
+ return authorizedMutation(path, {
+ method: "POST",
+ body: JSON.stringify(body),
+ })
+}
+
+export function removeUserSharedObject(id: number) {
+ return authorizedMutation