diff --git a/Cargo.toml b/Cargo.toml index 58ad758..e66d011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,9 @@ sha2 = "0.10" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "json", "chrono", "migrate"] } tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } +http-body-util = "0.1" hickory-resolver = "0.25" mlua = { version = "0.11", features = ["lua54", "async", "serialize", "vendored", "send"] } tracing = "0.1" diff --git a/src/server.rs b/src/server.rs index fed9bc2..5a92baf 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,11 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::routing::{get, post}; use axum::{Json, Router}; +use bytes::Bytes; +use http_body_util::Full; +use std::convert::Infallible; use tower_http::cors::CorsLayer; -use tower_http::services::{ServeDir, ServeFile}; +use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use crate::AppState; @@ -16,8 +19,44 @@ use crate::xrpc; pub fn router(state: AppState) -> Router { let static_dir = state.config.static_dir.clone(); - let index_path = format!("{}/index.html", static_dir); - let serve_dir = ServeDir::new(&static_dir).not_found_service(ServeFile::new(index_path)); + + // SPA fallback: when ServeDir can't find a static file, check if the + // parent path contains a _/index.html (Next.js dynamic route shell) + // before falling back to the root index.html. + let fallback_dir = static_dir.clone(); + let spa_fallback = tower::service_fn(move |req: axum::http::Request<_>| { + let dir = fallback_dir.clone(); + async move { + let path = req.uri().path(); + let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); + + // Try _/index.html in the parent directory (matches Next.js dynamic routes) + if segments.len() >= 2 { + let parent = segments[..segments.len() - 1].join("/"); + let dynamic_path = format!("{}/{}/_/index.html", dir, parent); + if let Ok(body) = tokio::fs::read(&dynamic_path).await { + return Ok::<_, Infallible>( + axum::http::Response::builder() + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(body))) + .unwrap(), + ); + } + } + + // Default: serve root index.html + let index = format!("{}/index.html", dir); + let body = tokio::fs::read(&index).await.unwrap_or_default(); + Ok::<_, Infallible>( + axum::http::Response::builder() + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(body))) + .unwrap(), + ) + } + }); + + let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); Router::new() .route("/health", get(health)) diff --git a/web/package-lock.json b/web/package-lock.json index 72c0774..39895ed 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -111,7 +111,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -739,7 +738,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2010,7 +2008,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4283,7 +4280,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4294,7 +4290,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4305,7 +4300,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4322,7 +4316,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4388,7 +4383,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4915,7 +4909,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5302,7 +5295,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5397,7 +5389,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6361,6 +6352,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6699,7 +6691,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6840,7 +6831,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7309,7 +7299,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8102,7 +8091,6 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9522,6 +9510,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11418,7 +11407,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11449,7 +11437,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11469,7 +11456,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11608,8 +11594,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12823,7 +12808,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13091,7 +13075,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13823,7 +13806,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx index f4fbae3..6119934 100644 --- a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; import { CodePanels } from "@/components/code-panels"; @@ -20,7 +20,8 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; export default function LexiconDetailPage() { - const { id } = useParams<{ id: string }>(); + const pathname = usePathname(); + const id = decodeURIComponent(pathname.split("/").filter(Boolean).pop() ?? ""); const { getToken } = useAuth(); const router = useRouter(); const [lexicon, setLexicon] = useState(null);