diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index 0fba953..144974d 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -12,6 +12,7 @@ const required = [ 'admin-roles.v1.ts', 'publish.v2.ts', 'admin-blogs.v1.ts', + 'admin-case-studies.v1.ts', ]; let failed = false; diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs index 593d787..51c4cc6 100644 --- a/apps/Administration-FE/scripts/check-i18n.mjs +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -18,6 +18,12 @@ const files = [ 'components/MediaPreview.tsx', 'components/CategoriesList.tsx', 'components/CategoryForm.tsx', + 'components/CaseStudiesList.tsx', + 'components/CaseStudyForm.tsx', + 'components/IndustriesList.tsx', + 'components/IndustryForm.tsx', + 'components/CaseStudyCategoriesList.tsx', + 'components/CaseStudyCategoryForm.tsx', 'components/MultiSelect.tsx', ]; const allowedLiteral = new Set(['Title', 'Description', 'Primary heading', 'Summary', 'Body']); diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index 4ba9b1a..cfe306a 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -10,6 +10,7 @@ const outDir = join(here, '../src/generated'); const authContractsDir = process.env.CONTRACTS_DIR || join(repoRoot, 'specs/002-auth-rbac/contracts'); const blogsContractsDir = join(repoRoot, 'specs/004-admin-blogs/contracts'); +const caseStudiesContractsDir = join(repoRoot, 'specs/005-admin-case-studies/contracts'); const contractSets = [ { @@ -26,6 +27,10 @@ const contractSets = [ dir: blogsContractsDir, files: ['admin-blogs.v1.yaml'], }, + { + dir: caseStudiesContractsDir, + files: ['admin-case-studies.v1.yaml'], + }, ]; mkdirSync(outDir, { recursive: true }); diff --git a/apps/Administration-FE/src/components/AdminApp.astro b/apps/Administration-FE/src/components/AdminApp.astro new file mode 100644 index 0000000..c998ca4 --- /dev/null +++ b/apps/Administration-FE/src/components/AdminApp.astro @@ -0,0 +1,9 @@ +--- +import AdminLayout from '../layouts/AdminLayout.astro'; +import AdminShell from './AdminShell'; +import { t } from '../lib/i18n'; +--- + + {t('admin.workspace.title')} + + diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index 86cda16..157d5ae 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type MouseEvent } from 'react'; import { getPageRecord, getSession, @@ -12,6 +12,13 @@ import { } from '../lib/admin-api'; import { hasTokens } from '../lib/token-store'; import { t } from '../lib/i18n'; +import { + adminFormHref, + adminListHref, + parseAdminLocation, + readAdminLocation, + type AdminView, +} from '../lib/admin-routes'; import PageEditor from './PageEditor'; import RoleForm from './RoleForm'; import RolesList from './RolesList'; @@ -23,18 +30,16 @@ import BlogsList from './BlogsList'; import BlogForm from './BlogForm'; import CategoriesList from './CategoriesList'; import CategoryForm from './CategoryForm'; +import CaseStudiesList from './CaseStudiesList'; +import CaseStudyForm from './CaseStudyForm'; +import IndustriesList from './IndustriesList'; +import IndustryForm from './IndustryForm'; +import CaseStudyCategoriesList from './CaseStudyCategoriesList'; +import CaseStudyCategoryForm from './CaseStudyCategoryForm'; +import TechnologiesList from './TechnologiesList'; +import TechnologyForm from './TechnologyForm'; -type View = - | 'site_settings' - | 'home' - | 'blogs' - | 'blog_form' - | 'authors' - | 'author_form' - | 'categories' - | 'category_form' - | 'roles' - | 'role_form'; +type View = AdminView; function canManageRolesFrom(session: SessionContext | null): boolean { return ( @@ -42,12 +47,71 @@ function canManageRolesFrom(session: SessionContext | null): boolean { ); } +function applyRoute( + route: ReturnType, + setters: { + setView: (view: View) => void; + setEditingRoleId: (id: string | null) => void; + setEditingBlogId: (id: string | null) => void; + setEditingCaseStudyId: (id: string | null) => void; + setEditingIndustryId: (id: string | null) => void; + setEditingCaseStudyCategoryId: (id: string | null) => void; + setEditingTechnologyId: (id: string | null) => void; + setEditingAuthorId: (id: string | null) => void; + setEditingCategoryId: (id: string | null) => void; + }, +) { + setters.setView(route.view); + setters.setEditingRoleId(route.view === 'role_form' ? route.editingId : null); + setters.setEditingBlogId(route.view === 'blog_form' ? route.editingId : null); + setters.setEditingCaseStudyId(route.view === 'case_study_form' ? route.editingId : null); + setters.setEditingIndustryId(route.view === 'industry_form' ? route.editingId : null); + setters.setEditingCaseStudyCategoryId( + route.view === 'case_study_category_form' ? route.editingId : null, + ); + setters.setEditingTechnologyId(route.view === 'technology_form' ? route.editingId : null); + setters.setEditingAuthorId(route.view === 'author_form' ? route.editingId : null); + setters.setEditingCategoryId(route.view === 'category_form' ? route.editingId : null); +} + export default function AdminShell() { - const [view, setView] = useState('site_settings'); - const [editingRoleId, setEditingRoleId] = useState(null); - const [editingBlogId, setEditingBlogId] = useState(null); - const [editingAuthorId, setEditingAuthorId] = useState(null); - const [editingCategoryId, setEditingCategoryId] = useState(null); + const [view, setView] = useState(() => readAdminLocation().view); + const [editingRoleId, setEditingRoleId] = useState( + () => { + const route = readAdminLocation(); + return route.view === 'role_form' ? route.editingId : null; + }, + ); + const [editingBlogId, setEditingBlogId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'blog_form' ? route.editingId : null; + }); + const [editingCaseStudyId, setEditingCaseStudyId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'case_study_form' ? route.editingId : null; + }); + const [editingIndustryId, setEditingIndustryId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'industry_form' ? route.editingId : null; + }); + const [editingCaseStudyCategoryId, setEditingCaseStudyCategoryId] = useState( + () => { + const route = readAdminLocation(); + return route.view === 'case_study_category_form' ? route.editingId : null; + }, + ); + const [editingTechnologyId, setEditingTechnologyId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'technology_form' ? route.editingId : null; + }); + const [editingAuthorId, setEditingAuthorId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'author_form' ? route.editingId : null; + }); + const [editingCategoryId, setEditingCategoryId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'category_form' ? route.editingId : null; + }); const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [homePage, setHomePage] = useState | null>(null); @@ -57,6 +121,36 @@ export default function AdminShell() { const [ready, setReady] = useState(!hasTokens()); const [navOpen, setNavOpen] = useState(false); + const applyLocation = useCallback((route: ReturnType) => { + applyRoute(route, { + setView, + setEditingRoleId, + setEditingBlogId, + setEditingCaseStudyId, + setEditingIndustryId, + setEditingCaseStudyCategoryId, + setEditingTechnologyId, + setEditingAuthorId, + setEditingCategoryId, + }); + }, []); + + const navigate = useCallback( + (href: string, replace = false) => { + const url = new URL(href, window.location.origin); + const next = parseAdminLocation(url.pathname, url.search); + const current = `${window.location.pathname}${window.location.search}`; + const target = `${url.pathname}${url.search}`; + if (replace) { + window.history.replaceState(null, '', target); + } else if (current !== target) { + window.history.pushState(null, '', target); + } + applyLocation(next); + }, + [applyLocation], + ); + const loadWorkspace = useCallback(async () => { setWorkspaceError(null); const nextSession = await getSession(); @@ -71,12 +165,12 @@ export default function AdminShell() { setHomePage(null); setWorkspaceError(t('admin.workspace.load_failed')); if (canManageRolesFrom(nextSession)) { - setView('roles'); + navigate(adminListHref('roles'), true); } } finally { setReady(true); } - }, []); + }, [navigate]); useEffect(() => { if (!hasTokens()) { @@ -89,6 +183,14 @@ export default function AdminShell() { }); }, [loadWorkspace]); + useEffect(() => { + function onPopState() { + applyLocation(readAdminLocation()); + } + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, [applyLocation]); + useEffect(() => { if (!session) { document.title = t('admin.sign_in.title'); @@ -102,6 +204,22 @@ export default function AdminShell() { document.title = t('admin.workspace.blogs'); return; } + if (view === 'case_studies' || view === 'case_study_form') { + document.title = t('admin.workspace.case_studies'); + return; + } + if (view === 'industries' || view === 'industry_form') { + document.title = t('admin.workspace.industries'); + return; + } + if (view === 'case_study_categories' || view === 'case_study_category_form') { + document.title = t('admin.workspace.case_study_categories'); + return; + } + if (view === 'technologies' || view === 'technology_form') { + document.title = t('admin.workspace.technologies'); + return; + } if (view === 'authors' || view === 'author_form') { document.title = t('admin.workspace.authors'); return; @@ -146,6 +264,9 @@ export default function AdminShell() { setWorkspaceError(null); try { await loadWorkspace(); + if (window.location.pathname.includes('sign-in')) { + navigate(adminListHref('site_settings'), true); + } } catch { // loadWorkspace sets workspaceError when records are missing } @@ -159,40 +280,59 @@ export default function AdminShell() { setMessage(null); setError(null); setWorkspaceError(null); - setView('site_settings'); - setEditingRoleId(null); - setEditingBlogId(null); - setEditingAuthorId(null); - setEditingCategoryId(null); setNavOpen(false); } - function openRoles() { - setView('roles'); - setEditingRoleId(null); + function openList(listView: View) { setError(null); setNavOpen(false); + navigate(adminListHref(listView)); } - function openBlogs() { - setView('blogs'); - setEditingBlogId(null); + function openForm(listView: View, id: string | null) { + setMessage(null); setError(null); - setNavOpen(false); + navigate(adminFormHref(listView, id)); + } + + function openRoles() { + openList('roles'); + } + + function openBlogs() { + openList('blogs'); + } + + function openCaseStudies() { + openList('case_studies'); + } + + function openIndustries() { + openList('industries'); + } + + function openCaseStudyCategories() { + openList('case_study_categories'); + } + + function openTechnologies() { + openList('technologies'); } function openAuthors() { - setView('authors'); - setEditingAuthorId(null); - setError(null); - setNavOpen(false); + openList('authors'); } function openCategories() { - setView('categories'); - setEditingCategoryId(null); - setError(null); - setNavOpen(false); + openList('categories'); + } + + function onNavClick(event: MouseEvent, href: string) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + event.preventDefault(); + openList(parseAdminLocation(href).view); } if (!ready) { @@ -255,71 +395,117 @@ export default function AdminShell() {