summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-25 00:01:25 +0000
committerGitHub <noreply@github.com>2026-01-25 00:01:25 +0000
commita279ec29efb863fefd1ca82e5b490f2e8784cf3c (patch)
treeaf207e559e7eef5557b2229714384bf78c530976 /frontend
parent6364363d1418728351f252b799d397b756e1f985 (diff)
downloadsoryu-a279ec29efb863fefd1ca82e5b490f2e8784cf3c.tar.gz
soryu-a279ec29efb863fefd1ca82e5b490f2e8784cf3c.zip
Move files tab and file pages to be accessible via contracts (#27)
* feat: remove Files from top-level navigation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: update file links to use contract-scoped routes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add contract context to FileDetail component - Add contractId, contractName, and onContractClick props to FileDetailProps - Update breadcrumb navigation to show contract name with path separator when viewing file within a contract context - Fall back to "Back to list" when no contract context is provided - This enables the FileDetail component to be used within the /contracts/:contractId/files/:fileId route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: update routes to nest files under contracts - Add react-router-dom for client-side routing - Create ContractList component to list all contracts - Create ContractDetail component with tabs (overview, files, tasks, repos) - Create FileDetail component to view individual files - Configure routes: - /contracts - list all contracts - /contracts/:id - view contract details with Files tab - /contracts/:contractId/files/:fileId - view file in contract context - Remove standalone file routes (/files, /files/:id) Files are now only accessible through their parent contract. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/package-lock.json50
-rw-r--r--frontend/package.json1
-rw-r--r--frontend/src/components/ContractDetail.tsx232
-rw-r--r--frontend/src/components/ContractList.tsx83
-rw-r--r--frontend/src/components/FileDetail.tsx97
-rw-r--r--frontend/src/main.tsx33
-rw-r--r--frontend/tsconfig.tsbuildinfo2
7 files changed, 496 insertions, 2 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d7c37c7..230ed07 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,6 +13,7 @@
"nanostores": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-router-dom": "^6.22.0",
"three": "^0.180.0"
},
"devDependencies": {
@@ -65,6 +66,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -726,6 +728,15 @@
"react": ">=18.0.0"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1049,6 +1060,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true,
+ "peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1064,6 +1076,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"dev": true,
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1146,6 +1159,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -1377,6 +1391,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
@@ -1425,6 +1440,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1436,6 +1452,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1453,6 +1470,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/rollup": {
"version": "4.49.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",
@@ -1577,6 +1626,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
diff --git a/frontend/package.json b/frontend/package.json
index 53e4c2c..197c3d8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"nanostores": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-router-dom": "^6.22.0",
"three": "^0.180.0"
},
"devDependencies": {
diff --git a/frontend/src/components/ContractDetail.tsx b/frontend/src/components/ContractDetail.tsx
new file mode 100644
index 0000000..72527ce
--- /dev/null
+++ b/frontend/src/components/ContractDetail.tsx
@@ -0,0 +1,232 @@
+import React, { useEffect, useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+
+interface FileSummary {
+ id: string
+ name: string
+ description?: string
+ contract_phase?: string
+}
+
+interface TaskSummary {
+ id: string
+ name: string
+ status: string
+}
+
+interface ContractRepository {
+ id: string
+ name: string
+ source_type: string
+ is_primary: boolean
+}
+
+interface Contract {
+ id: string
+ name: string
+ description?: string
+ contract_type: string
+ phase: string
+ status: string
+ version: number
+ created_at: string
+}
+
+interface ContractWithRelations {
+ contract: Contract
+ repositories: ContractRepository[]
+ files: FileSummary[]
+ tasks: TaskSummary[]
+}
+
+type Tab = 'overview' | 'files' | 'tasks' | 'repositories'
+
+export function ContractDetail() {
+ const { id } = useParams<{ id: string }>()
+ const [data, setData] = useState<ContractWithRelations | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+ const [activeTab, setActiveTab] = useState<Tab>('overview')
+
+ useEffect(() => {
+ async function fetchContract() {
+ if (!id) return
+
+ try {
+ setLoading(true)
+ const response = await fetch(`/api/v1/contracts/${id}`)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch contract: ${response.statusText}`)
+ }
+ const contractData = await response.json()
+ setData(contractData)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchContract()
+ }, [id])
+
+ if (loading) {
+ return (
+ <div className="contract-detail-container">
+ <div className="loading">Loading contract...</div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="contract-detail-container">
+ <div className="error">Error: {error}</div>
+ <Link to="/contracts" className="back-link">
+ Back to Contracts
+ </Link>
+ </div>
+ )
+ }
+
+ if (!data) {
+ return (
+ <div className="contract-detail-container">
+ <div className="not-found">Contract not found</div>
+ <Link to="/contracts" className="back-link">
+ Back to Contracts
+ </Link>
+ </div>
+ )
+ }
+
+ const { contract, repositories, files, tasks } = data
+
+ return (
+ <div className="contract-detail-container">
+ <div className="contract-detail-header">
+ <Link to="/contracts" className="back-link">
+ Back to Contracts
+ </Link>
+ <h1 className="contract-title">{contract.name}</h1>
+ {contract.description && (
+ <p className="contract-description">{contract.description}</p>
+ )}
+ <div className="contract-meta">
+ <span>Phase: {contract.phase}</span>
+ <span>Status: {contract.status}</span>
+ <span>Version: {contract.version}</span>
+ </div>
+ </div>
+
+ <div className="contract-tabs">
+ <button
+ className={`tab-button ${activeTab === 'overview' ? 'active' : ''}`}
+ onClick={() => setActiveTab('overview')}
+ >
+ Overview
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'files' ? 'active' : ''}`}
+ onClick={() => setActiveTab('files')}
+ >
+ Files ({files.length})
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'tasks' ? 'active' : ''}`}
+ onClick={() => setActiveTab('tasks')}
+ >
+ Tasks ({tasks.length})
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'repositories' ? 'active' : ''}`}
+ onClick={() => setActiveTab('repositories')}
+ >
+ Repositories ({repositories.length})
+ </button>
+ </div>
+
+ <div className="contract-tab-content">
+ {activeTab === 'overview' && (
+ <div className="tab-panel">
+ <h2>Contract Overview</h2>
+ <dl className="overview-list">
+ <dt>Type</dt>
+ <dd>{contract.contract_type}</dd>
+ <dt>Phase</dt>
+ <dd>{contract.phase}</dd>
+ <dt>Status</dt>
+ <dd>{contract.status}</dd>
+ <dt>Created</dt>
+ <dd>{new Date(contract.created_at).toLocaleString()}</dd>
+ </dl>
+ </div>
+ )}
+
+ {activeTab === 'files' && (
+ <div className="tab-panel">
+ <h2>Files</h2>
+ {files.length === 0 ? (
+ <p>No files in this contract</p>
+ ) : (
+ <ul className="file-list">
+ {files.map((file) => (
+ <li key={file.id} className="file-item">
+ <Link to={`/contracts/${contract.id}/files/${file.id}`}>
+ <h3>{file.name}</h3>
+ {file.description && <p>{file.description}</p>}
+ {file.contract_phase && (
+ <span className="file-phase">Phase: {file.contract_phase}</span>
+ )}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ )}
+
+ {activeTab === 'tasks' && (
+ <div className="tab-panel">
+ <h2>Tasks</h2>
+ {tasks.length === 0 ? (
+ <p>No tasks in this contract</p>
+ ) : (
+ <ul className="task-list">
+ {tasks.map((task) => (
+ <li key={task.id} className="task-item">
+ <h3>{task.name}</h3>
+ <span className={`task-status status-${task.status}`}>
+ {task.status}
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ )}
+
+ {activeTab === 'repositories' && (
+ <div className="tab-panel">
+ <h2>Repositories</h2>
+ {repositories.length === 0 ? (
+ <p>No repositories linked to this contract</p>
+ ) : (
+ <ul className="repository-list">
+ {repositories.map((repo) => (
+ <li key={repo.id} className="repository-item">
+ <h3>
+ {repo.name}
+ {repo.is_primary && <span className="primary-badge">Primary</span>}
+ </h3>
+ <span className="repo-type">{repo.source_type}</span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/frontend/src/components/ContractList.tsx b/frontend/src/components/ContractList.tsx
new file mode 100644
index 0000000..77012db
--- /dev/null
+++ b/frontend/src/components/ContractList.tsx
@@ -0,0 +1,83 @@
+import React, { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+
+interface ContractSummary {
+ id: string
+ name: string
+ description?: string
+ contract_type: string
+ phase: string
+ status: string
+ file_count: number
+ task_count: number
+ repository_count: number
+ created_at: string
+}
+
+export function ContractList() {
+ const [contracts, setContracts] = useState<ContractSummary[]>([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+
+ useEffect(() => {
+ async function fetchContracts() {
+ try {
+ setLoading(true)
+ const response = await fetch('/api/v1/contracts')
+ if (!response.ok) {
+ throw new Error(`Failed to fetch contracts: ${response.statusText}`)
+ }
+ const data = await response.json()
+ setContracts(data.contracts || [])
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchContracts()
+ }, [])
+
+ if (loading) {
+ return (
+ <div className="contract-list-container">
+ <div className="loading">Loading contracts...</div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="contract-list-container">
+ <div className="error">Error: {error}</div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="contract-list-container">
+ <h1>Contracts</h1>
+ {contracts.length === 0 ? (
+ <p>No contracts found</p>
+ ) : (
+ <ul className="contract-list">
+ {contracts.map((contract) => (
+ <li key={contract.id} className="contract-item">
+ <Link to={`/contracts/${contract.id}`}>
+ <h2>{contract.name}</h2>
+ {contract.description && <p>{contract.description}</p>}
+ <div className="contract-meta">
+ <span>Phase: {contract.phase}</span>
+ <span>Status: {contract.status}</span>
+ <span>Files: {contract.file_count}</span>
+ <span>Tasks: {contract.task_count}</span>
+ </div>
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ )
+}
diff --git a/frontend/src/components/FileDetail.tsx b/frontend/src/components/FileDetail.tsx
new file mode 100644
index 0000000..31228ef
--- /dev/null
+++ b/frontend/src/components/FileDetail.tsx
@@ -0,0 +1,97 @@
+import React, { useEffect, useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+
+interface File {
+ id: string
+ name: string
+ description?: string
+ body?: string
+ contract_id?: string
+ version: number
+ created_at: string
+}
+
+export function FileDetail() {
+ const { contractId, fileId } = useParams<{ contractId: string; fileId: string }>()
+ const [file, setFile] = useState<File | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+
+ useEffect(() => {
+ async function fetchFile() {
+ if (!fileId) return
+
+ try {
+ setLoading(true)
+ const response = await fetch(`/api/v1/files/${fileId}`)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch file: ${response.statusText}`)
+ }
+ const data = await response.json()
+ setFile(data)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchFile()
+ }, [fileId])
+
+ if (loading) {
+ return (
+ <div className="file-detail-container">
+ <div className="loading">Loading file...</div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="file-detail-container">
+ <div className="error">Error: {error}</div>
+ <Link to={`/contracts/${contractId}`} className="back-link">
+ Back to Contract
+ </Link>
+ </div>
+ )
+ }
+
+ if (!file) {
+ return (
+ <div className="file-detail-container">
+ <div className="not-found">File not found</div>
+ <Link to={`/contracts/${contractId}`} className="back-link">
+ Back to Contract
+ </Link>
+ </div>
+ )
+ }
+
+ return (
+ <div className="file-detail-container">
+ <div className="file-detail-header">
+ <Link to={`/contracts/${contractId}`} className="back-link">
+ Back to Contract
+ </Link>
+ <h1 className="file-title">{file.name}</h1>
+ {file.description && (
+ <p className="file-description">{file.description}</p>
+ )}
+ <div className="file-meta">
+ <span>Version: {file.version}</span>
+ <span>Created: {new Date(file.created_at).toLocaleString()}</span>
+ </div>
+ </div>
+
+ <div className="file-detail-body">
+ {file.body ? (
+ <pre className="file-content">{file.body}</pre>
+ ) : (
+ <p className="no-content">No content</p>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 9373927..a6eae5b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,11 +1,42 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
+import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App'
+import { ContractList } from './components/ContractList'
+import { ContractDetail } from './components/ContractDetail'
+import { FileDetail } from './components/FileDetail'
import './styles/pc98.css'
import './styles/mobile.css'
+// Route configuration:
+// - /contracts - List all contracts
+// - /contracts/:id - View contract details with tabs (including Files tab)
+// - /contracts/:contractId/files/:fileId - View a specific file within contract context
+//
+// Note: Standalone file routes (/files, /files/:id) have been removed.
+// Files are now only accessible through their parent contract.
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: <App />,
+ },
+ {
+ path: '/contracts',
+ element: <ContractList />,
+ },
+ {
+ path: '/contracts/:id',
+ element: <ContractDetail />,
+ },
+ {
+ path: '/contracts/:contractId/files/:fileId',
+ element: <FileDetail />,
+ },
+])
+
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
- <App />
+ <RouterProvider router={router} />
</React.StrictMode>
)
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 93d1fec..79408dc 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/dialoguebox.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file