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 @@ +

+
+ + atlas logo / Atlas + +
+

+

+ + + +/ +library +

+https://avatars.githubusercontent.com/u/90376906?s=400&u=7592339d75e23b1e5b5db486c48271f6491d1422&v=4 +

Atlas BI Library (Next.js Frontend) | The unified report library.

+ +

+ WebsiteDemoDocumentationChat +

+ +

+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. +

+ +

+ codacy badge + + coverage badge + + +discord chat +latest release +

+ +## 🏃 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. + + + + /> + +gitguardian renovate snyk quality gate sonar +semantic-release [![Lines of + Code](https://sonarcloud.io/api/project_badges/measure?project=atlas-bi_atlas-bi-library&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=atlas-bi_atlas-bi-library) +[![Maintainability + Rating](https://sonarcloud.io/api/project_badges/measure?project=atlas-bi_atlas-bi-library&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=atlas-bi_atlas-bi-library) +[![Security + Rating](https://sonarcloud.io/api/project_badges/measure?project=atlas-bi_atlas-bi-library&metric=security_rating)](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. +

+ +
+
+
+ ) + } + + 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. +

+ +
+ ) + } + + const result = await getCollectionById(id) + const collection = result.data + if (!collection) { + return ( +
+ + + Unable to load collection + + + + + +
+ ) + } + + if (!collection.canEditCollection) { + return ( +
+ + + Editing not available + + +

+ You do not have access to edit this 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. +

+ +
+
+
+ ) + } + + 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. +

+ +
+ ) + } + + const result = await getCollectionById(id) + const collection = result.data + if (!collection) { + const message = getUserFriendlyErrorMessage(result.error ?? "unknown") + return ( + + + + Unable to load collection + + +

{message}

+ +
+
+
+ ) + } + + 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} + +
+
+
+ ) + } + + const totalPages = Math.max(1, Math.ceil(list.total / list.pageSize)) + + return ( + +
+

Collections

+ {canCreateCollection ? ( +
+ + + + Create a Collection + +
+ ) : null} +
+ + {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 ? ( + + ) : ( + + )} + {page >= totalPages ? ( + + ) : ( + + )} +
+
+ ) : 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. +

+
+ + +
+
+
+
+ ) +} 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. +

+
+ + +
+
+
+ + + ) +} 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.

+ +
+ ) + } + + 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} +

+ +
+
+
+ ) + } + + 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. +

+ +
+ } + /> +
+
+ ) +} + +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 ( +
+
+

Groups

+
+ +
+ ) +} 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. +

+ +
+
+
+ ) + } + + 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.

+ +
+ ) + } + + const result = await getReportDetailById(id) + const report = result.data + if (!report) { + return ( +
+ + + Unable to load report + + + + + +
+ ) + } + + if (!report.canEditDocumentation) { + return ( +
+ + + Editing not available + + +

+ You do not have access to edit this 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.

+ +
+ ) + } + + 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 + + +
    + {report.queries.map((query) => ( +
  • +
    {query.name ?? `Query ${query.id}`}
    + {query.language ? ( +
    Language: {query.language}
    + ) : null} + {query.source ? ( +
    {query.source}
    + ) : null} +
  • + ))} +
+
+
+ ) : null} + + {report.componentQueries && report.componentQueries.length > 0 ? ( + + + Component Queries + + +
    + {report.componentQueries.map((query) => ( +
  • +
    {query.name ?? `Query ${query.id}`}
    + {query.language ? ( +
    Language: {query.language}
    + ) : null} +
  • + ))} +
+
+
+ ) : null} + + {Array.isArray(report.images) && report.images.length > 0 ? ( + + + Images + + +
+ {report.images.map((image) => ( +
+ {image.source ? ( + {`Report + ) : 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}

+ +
+
+
+ ) + } + + 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 ( +
+ + +
+
+
+ {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 ? ( +
+ + +
+ ) : 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} +

+ +
+ ) + } + + 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 ( + +
+

{title}

+
+ + + ) : ( +
+ 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. +

+ +
+ ) : ( +
+ 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 ( +
+

{title}

+ {children} +
+ ) +} + +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 ( +
    + {rows.map((row, index) => ( +
  • + + {row.label} + +
    + + + +
    +
  • + ))} +
+ ) +} + +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}

+ +
+ + +
+ + + + Basics + + +
+ + { + setName(event.target.value) + }} + required + /> +
+
+ { + setHidden(checked) + }} + /> + +
+
+
+ + + + Content + + +
+ +

+ Supports Markdown. Shown as search summary. +

+