compendious commited on
Commit
e35e5e3
·
1 Parent(s): b813321
.gitignore CHANGED
@@ -1,2 +1,9 @@
 
 
1
  **cache**
2
  *.ipynb
 
 
 
 
 
 
1
+
2
+ # Python Remainders
3
  **cache**
4
  *.ipynb
5
+ .venv
6
+
7
+ # Front end
8
+ node_modules
9
+ package-lock.json
backend/app.py CHANGED
@@ -1,6 +1,7 @@
1
  """FastAPI backend for Précis."""
2
 
3
- from fastapi import FastAPI, HTTPException
 
4
  from fastapi.responses import HTMLResponse
5
  from pydantic import BaseModel
6
  from typing import Optional
@@ -11,18 +12,30 @@ app = FastAPI(
11
  version="0.1.0"
12
  )
13
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- class SummarizeRequest(BaseModel):
16
- """Request model for summarization."""
17
  url: str
18
  max_length: Optional[int] = 512
19
 
 
 
 
20
 
21
  class SummarizeResponse(BaseModel):
22
- """Response model for summarization."""
23
- url: str
24
  summary: str
25
  success: bool
 
 
26
 
27
 
28
  @app.get("/", response_class=HTMLResponse)
@@ -72,32 +85,44 @@ async def status():
72
  }
73
 
74
 
75
- @app.post("/summarize", response_model=SummarizeResponse)
76
- async def summarize(request: SummarizeRequest):
77
- """
78
- Summarize content from a URL.
79
-
80
- Currently returns dummy data. Will be implemented with actual model.
81
- """
82
- # TODO: Implement actual summarization
83
- # 1. Fetch content from URL
84
- # 2. Parse text (YouTube transcript or article)
85
- # 3. Run through model
86
- # 4. Return summary
87
-
88
- dummy_summary = (
89
- f"This is a placeholder summary for content at {request.url}. "
90
- "The actual summarization model will be integrated in the next phase. "
91
- "This summary respects the max_length parameter of {request.max_length} tokens."
 
92
  )
 
 
 
 
 
 
93
 
 
 
 
 
94
  return SummarizeResponse(
95
- url=request.url,
96
- summary=dummy_summary,
97
- success=True
98
  )
99
 
100
 
 
101
  if __name__ == "__main__":
102
  import uvicorn
103
  uvicorn.run(app, host="0.0.0.0", port=8000)
 
1
  """FastAPI backend for Précis."""
2
 
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form
5
  from fastapi.responses import HTMLResponse
6
  from pydantic import BaseModel
7
  from typing import Optional
 
12
  version="0.1.0"
13
  )
14
 
15
+ # Add CORS middleware
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_credentials=True,
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+
25
 
26
+ class YouTubeRequest(BaseModel):
 
27
  url: str
28
  max_length: Optional[int] = 512
29
 
30
+ class TranscriptRequest(BaseModel):
31
+ text: str
32
+ max_length: Optional[int] = 512
33
 
34
  class SummarizeResponse(BaseModel):
 
 
35
  summary: str
36
  success: bool
37
+ source_type: str
38
+
39
 
40
 
41
  @app.get("/", response_class=HTMLResponse)
 
85
  }
86
 
87
 
88
+ @app.post("/summarize/youtube", response_model=SummarizeResponse)
89
+ async def summarize_youtube(request: YouTubeRequest):
90
+ """Summarize a YouTube video from its URL."""
91
+ # TODO: Implement YT transcript extraction and summarization
92
+ return SummarizeResponse(
93
+ summary=f"Summary for YouTube video at {request.url}. (Placeholder)",
94
+ success=True,
95
+ source_type="youtube"
96
+ )
97
+
98
+ @app.post("/summarize/transcript", response_model=SummarizeResponse)
99
+ async def summarize_transcript(request: TranscriptRequest):
100
+ """Summarize a provided transcript or article text."""
101
+ # TODO: Implement summarization
102
+ return SummarizeResponse(
103
+ summary=f"Summary for provided text ({len(request.text)} chars). (Placeholder)",
104
+ success=True,
105
+ source_type="transcript"
106
  )
107
+
108
+ @app.post("/summarize/file", response_model=SummarizeResponse)
109
+ async def summarize_file(file: UploadFile = File(...)):
110
+ """Summarize content from a .txt file."""
111
+ if not file.filename.endswith(".txt"):
112
+ raise HTTPException(status_code=400, detail="Only .txt files are supported")
113
 
114
+ content = await file.read()
115
+ text = content.decode("utf-8")
116
+
117
+ # TODO: Implement summarization
118
  return SummarizeResponse(
119
+ summary=f"Summary for file {file.filename} ({len(text)} chars). (Placeholder)",
120
+ success=True,
121
+ source_type="file"
122
  )
123
 
124
 
125
+
126
  if __name__ == "__main__":
127
  import uvicorn
128
  uvicorn.run(app, host="0.0.0.0", port=8000)
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend
2
+
3
+ A GitHub-inspired dark theme frontend for the Précis content summarization API.
4
+
5
+ ## Features
6
+
7
+ - **YouTube Video Summarization**: Paste a YouTube URL to summarize video content
8
+ - **Article/Transcript Summarization**: Paste any text directly to summarize
9
+ - **File Upload**: Drag and drop or browse for `.txt` files to summarize
10
+
11
+ ## Prerequisites
12
+
13
+ - Node.js 18+ (or Bun)
14
+ - The backend API running at `http://localhost:8000`
15
+
16
+ ## Getting Started
17
+
18
+ ### 1. Install Dependencies
19
+
20
+ ```bash
21
+ npm install
22
+ ```
23
+
24
+ Or with Bun:
25
+
26
+ ```bash
27
+ bun install
28
+ ```
29
+
30
+ ### 2. Start the Development Server
31
+
32
+ ```bash
33
+ npm run dev
34
+ ```
35
+
36
+ Or with Bun:
37
+
38
+ ```bash
39
+ bun run dev
40
+ ```
41
+
42
+ The frontend will be available at [http://localhost:5173](http://localhost:5173).
43
+
44
+ ### 3. Start the Backend API (Required)
45
+
46
+ In a separate terminal, navigate to the backend directory and run:
47
+
48
+ ```bash
49
+ cd ../backend
50
+ python -m uvicorn app:app --reload --host 0.0.0.0 --port 8000
51
+ ```
52
+
53
+ ## API Endpoints Used
54
+
55
+ | Endpoint | Method | Description |
56
+ |----------|--------|-------------|
57
+ | `/summarize/youtube` | POST | Summarize YouTube video |
58
+ | `/summarize/transcript` | POST | Summarize text content |
59
+ | `/summarize/file` | POST | Summarize uploaded .txt file |
60
+
61
+ ## Build for Production
62
+
63
+ ```bash
64
+ npm run build
65
+ ```
66
+
67
+ The output will be in the `dist/` directory.
frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.2.0",
14
+ "react-dom": "^19.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^9.39.1",
18
+ "@types/react": "^19.2.5",
19
+ "@types/react-dom": "^19.2.3",
20
+ "@vitejs/plugin-react": "^5.1.1",
21
+ "eslint": "^9.39.1",
22
+ "eslint-plugin-react-hooks": "^7.0.1",
23
+ "eslint-plugin-react-refresh": "^0.4.24",
24
+ "globals": "^16.5.0",
25
+ "vite": "^7.2.4"
26
+ }
27
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Header */
2
+ .header {
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: space-between;
6
+ padding: var(--spacing-3) var(--spacing-4);
7
+ background-color: var(--color-canvas-subtle);
8
+ border-bottom: 1px solid var(--color-border-default);
9
+ }
10
+
11
+ .logo {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--spacing-2);
15
+ font-size: 20px;
16
+ font-weight: 600;
17
+ color: var(--color-fg-default);
18
+ text-decoration: none;
19
+ }
20
+
21
+ .logo-icon {
22
+ width: 32px;
23
+ height: 32px;
24
+ }
25
+
26
+ /* Main content */
27
+ .main {
28
+ flex: 1;
29
+ padding: var(--spacing-6) 0;
30
+ }
31
+
32
+ .page-title {
33
+ font-size: 32px;
34
+ font-weight: 600;
35
+ margin-bottom: var(--spacing-2);
36
+ }
37
+
38
+ .page-subtitle {
39
+ font-size: 16px;
40
+ color: var(--color-fg-muted);
41
+ margin-bottom: var(--spacing-5);
42
+ }
43
+
44
+ /* Upload section */
45
+ .upload-section {
46
+ max-width: 800px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .upload-card {
51
+ background-color: var(--color-canvas-subtle);
52
+ border: 1px solid var(--color-border-default);
53
+ border-radius: var(--radius-2);
54
+ overflow: hidden;
55
+ }
56
+
57
+ .upload-header {
58
+ padding: var(--spacing-3) var(--spacing-4);
59
+ background-color: var(--color-canvas-inset);
60
+ border-bottom: 1px solid var(--color-border-muted);
61
+ }
62
+
63
+ .upload-title {
64
+ font-size: 14px;
65
+ font-weight: 600;
66
+ display: flex;
67
+ align-items: center;
68
+ gap: var(--spacing-2);
69
+ }
70
+
71
+ .upload-title svg {
72
+ width: 16px;
73
+ height: 16px;
74
+ color: var(--color-fg-muted);
75
+ }
76
+
77
+ .upload-body {
78
+ padding: var(--spacing-4);
79
+ }
80
+
81
+ /* Tab content panels */
82
+ .tab-panel {
83
+ display: none;
84
+ }
85
+
86
+ .tab-panel.active {
87
+ display: block;
88
+ animation: fadeIn 0.2s ease-out;
89
+ }
90
+
91
+ /* Form groups */
92
+ .form-group {
93
+ margin-bottom: var(--spacing-4);
94
+ }
95
+
96
+ .form-label {
97
+ display: block;
98
+ font-size: 14px;
99
+ font-weight: 500;
100
+ color: var(--color-fg-default);
101
+ margin-bottom: var(--spacing-2);
102
+ }
103
+
104
+ .form-hint {
105
+ font-size: 12px;
106
+ color: var(--color-fg-muted);
107
+ margin-top: var(--spacing-1);
108
+ }
109
+
110
+ /* File input styling */
111
+ .file-input {
112
+ display: none;
113
+ }
114
+
115
+ .file-selected {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: var(--spacing-3);
119
+ padding: var(--spacing-3);
120
+ background-color: var(--color-canvas-default);
121
+ border: 1px solid var(--color-border-default);
122
+ border-radius: var(--radius-1);
123
+ margin-top: var(--spacing-3);
124
+ }
125
+
126
+ .file-icon {
127
+ width: 32px;
128
+ height: 32px;
129
+ display: flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ background-color: var(--color-accent-emphasis);
133
+ border-radius: var(--radius-1);
134
+ color: white;
135
+ }
136
+
137
+ .file-info {
138
+ flex: 1;
139
+ }
140
+
141
+ .file-name {
142
+ font-size: 14px;
143
+ font-weight: 500;
144
+ color: var(--color-fg-default);
145
+ }
146
+
147
+ .file-size {
148
+ font-size: 12px;
149
+ color: var(--color-fg-muted);
150
+ }
151
+
152
+ .file-remove {
153
+ padding: var(--spacing-1);
154
+ background: transparent;
155
+ border: none;
156
+ color: var(--color-fg-muted);
157
+ cursor: pointer;
158
+ border-radius: var(--radius-1);
159
+ transition: color var(--transition-fast), background-color var(--transition-fast);
160
+ }
161
+
162
+ .file-remove:hover {
163
+ color: var(--color-danger-fg);
164
+ background-color: rgba(248, 81, 73, 0.1);
165
+ }
166
+
167
+ /* Submit section */
168
+ .submit-section {
169
+ display: flex;
170
+ justify-content: flex-end;
171
+ padding-top: var(--spacing-4);
172
+ border-top: 1px solid var(--color-border-muted);
173
+ margin-top: var(--spacing-4);
174
+ }
175
+
176
+ /* Response section */
177
+ .response-section {
178
+ margin-top: var(--spacing-5);
179
+ }
180
+
181
+ .response-card {
182
+ background-color: var(--color-canvas-subtle);
183
+ border: 1px solid var(--color-border-default);
184
+ border-radius: var(--radius-2);
185
+ overflow: hidden;
186
+ }
187
+
188
+ .response-header {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: space-between;
192
+ padding: var(--spacing-3) var(--spacing-4);
193
+ background-color: var(--color-canvas-inset);
194
+ border-bottom: 1px solid var(--color-border-muted);
195
+ }
196
+
197
+ .response-title {
198
+ font-size: 14px;
199
+ font-weight: 600;
200
+ display: flex;
201
+ align-items: center;
202
+ gap: var(--spacing-2);
203
+ }
204
+
205
+ .response-badge {
206
+ font-size: 12px;
207
+ padding: 2px 8px;
208
+ border-radius: var(--radius-full);
209
+ background-color: var(--color-btn-primary-bg);
210
+ color: white;
211
+ }
212
+
213
+ .response-body {
214
+ padding: var(--spacing-4);
215
+ }
216
+
217
+ .response-text {
218
+ font-size: 14px;
219
+ line-height: 1.7;
220
+ color: var(--color-fg-default);
221
+ white-space: pre-wrap;
222
+ }
223
+
224
+ /* Loading state */
225
+ .loading {
226
+ display: flex;
227
+ flex-direction: column;
228
+ align-items: center;
229
+ gap: var(--spacing-3);
230
+ padding: var(--spacing-6);
231
+ color: var(--color-fg-muted);
232
+ }
233
+
234
+ .loading-spinner {
235
+ width: 24px;
236
+ height: 24px;
237
+ border: 2px solid var(--color-border-default);
238
+ border-top-color: var(--color-accent-fg);
239
+ border-radius: 50%;
240
+ animation: spin 0.8s linear infinite;
241
+ }
242
+
243
+ @keyframes spin {
244
+ to {
245
+ transform: rotate(360deg);
246
+ }
247
+ }
248
+
249
+ /* Footer */
250
+ .footer {
251
+ padding: var(--spacing-4);
252
+ text-align: center;
253
+ font-size: 12px;
254
+ color: var(--color-fg-subtle);
255
+ border-top: 1px solid var(--color-border-muted);
256
+ }
257
+
258
+ .footer a {
259
+ color: var(--color-accent-fg);
260
+ text-decoration: none;
261
+ }
262
+
263
+ .footer a:hover {
264
+ text-decoration: underline;
265
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from 'react'
2
+ import './App.css'
3
+
4
+ const API_BASE = 'http://localhost:8000'
5
+
6
+ function App() {
7
+ const [activeTab, setActiveTab] = useState('youtube')
8
+ const [youtubeUrl, setYoutubeUrl] = useState('')
9
+ const [transcript, setTranscript] = useState('')
10
+ const [selectedFile, setSelectedFile] = useState(null)
11
+ const [loading, setLoading] = useState(false)
12
+ const [response, setResponse] = useState(null)
13
+ const [error, setError] = useState(null)
14
+ const fileInputRef = useRef(null)
15
+
16
+ const handleSubmit = async () => {
17
+ setLoading(true)
18
+ setError(null)
19
+ setResponse(null)
20
+
21
+ try {
22
+ let result
23
+
24
+ if (activeTab === 'youtube') {
25
+ if (!youtubeUrl.trim()) {
26
+ throw new Error('Please enter a YouTube URL')
27
+ }
28
+ const res = await fetch(`${API_BASE}/summarize/youtube`, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ url: youtubeUrl })
32
+ })
33
+ result = await res.json()
34
+ } else if (activeTab === 'transcript') {
35
+ if (!transcript.trim()) {
36
+ throw new Error('Please enter some text')
37
+ }
38
+ const res = await fetch(`${API_BASE}/summarize/transcript`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ text: transcript })
42
+ })
43
+ result = await res.json()
44
+ } else if (activeTab === 'file') {
45
+ if (!selectedFile) {
46
+ throw new Error('Please select a file')
47
+ }
48
+ const formData = new FormData()
49
+ formData.append('file', selectedFile)
50
+ const res = await fetch(`${API_BASE}/summarize/file`, {
51
+ method: 'POST',
52
+ body: formData
53
+ })
54
+ result = await res.json()
55
+ }
56
+
57
+ setResponse(result)
58
+ } catch (err) {
59
+ setError(err.message || 'An error occurred')
60
+ } finally {
61
+ setLoading(false)
62
+ }
63
+ }
64
+
65
+ const handleFileDrop = (e) => {
66
+ e.preventDefault()
67
+ e.stopPropagation()
68
+ const file = e.dataTransfer?.files[0] || e.target.files?.[0]
69
+ if (file && file.name.endsWith('.txt')) {
70
+ setSelectedFile(file)
71
+ } else if (file) {
72
+ setError('Only .txt files are supported')
73
+ }
74
+ }
75
+
76
+ const formatFileSize = (bytes) => {
77
+ if (bytes < 1024) return bytes + ' bytes'
78
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
79
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
80
+ }
81
+
82
+ return (
83
+ <>
84
+ <header className="header">
85
+ <a href="/" className="logo">
86
+ <svg className="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
87
+ <path d="M12 2L2 7l10 5 10-5-10-5z" />
88
+ <path d="M2 17l10 5 10-5" />
89
+ <path d="M2 12l10 5 10-5" />
90
+ </svg>
91
+ Précis
92
+ </a>
93
+ <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn">
94
+ API Docs
95
+ </a>
96
+ </header>
97
+
98
+ <main className="main">
99
+ <div className="container">
100
+ <div className="upload-section fade-in">
101
+ <h1 className="page-title">Summarize Content</h1>
102
+ <p className="page-subtitle">
103
+ Upload a YouTube video, paste a transcript, or drop a text file to generate a summary.
104
+ </p>
105
+
106
+ <div className="upload-card">
107
+ <div className="upload-header">
108
+ <div className="upload-title">
109
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
110
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
111
+ <polyline points="17 8 12 3 7 8" />
112
+ <line x1="12" y1="3" x2="12" y2="15" />
113
+ </svg>
114
+ Upload Content
115
+ </div>
116
+ </div>
117
+
118
+ <div className="upload-body">
119
+ <div className="tabs">
120
+ <button
121
+ className={`tab ${activeTab === 'youtube' ? 'active' : ''}`}
122
+ onClick={() => setActiveTab('youtube')}
123
+ >
124
+ YouTube Video
125
+ </button>
126
+ <button
127
+ className={`tab ${activeTab === 'transcript' ? 'active' : ''}`}
128
+ onClick={() => setActiveTab('transcript')}
129
+ >
130
+ Article / Transcript
131
+ </button>
132
+ <button
133
+ className={`tab ${activeTab === 'file' ? 'active' : ''}`}
134
+ onClick={() => setActiveTab('file')}
135
+ >
136
+ Text File
137
+ </button>
138
+ </div>
139
+
140
+ {/* YouTube Tab */}
141
+ <div className={`tab-panel ${activeTab === 'youtube' ? 'active' : ''}`}>
142
+ <div className="form-group">
143
+ <label className="form-label">YouTube URL</label>
144
+ <input
145
+ type="url"
146
+ className="input"
147
+ placeholder="https://www.youtube.com/watch?v=..."
148
+ value={youtubeUrl}
149
+ onChange={(e) => setYoutubeUrl(e.target.value)}
150
+ />
151
+ <p className="form-hint">Paste the full URL of a YouTube video to summarize its content.</p>
152
+ </div>
153
+ </div>
154
+
155
+ {/* Transcript Tab */}
156
+ <div className={`tab-panel ${activeTab === 'transcript' ? 'active' : ''}`}>
157
+ <div className="form-group">
158
+ <label className="form-label">Article or Transcript Text</label>
159
+ <textarea
160
+ className="textarea"
161
+ placeholder="Paste your article or transcript here..."
162
+ value={transcript}
163
+ onChange={(e) => setTranscript(e.target.value)}
164
+ />
165
+ <p className="form-hint">Paste any text content you want to summarize.</p>
166
+ </div>
167
+ </div>
168
+
169
+ {/* File Tab */}
170
+ <div className={`tab-panel ${activeTab === 'file' ? 'active' : ''}`}>
171
+ <div className="form-group">
172
+ <label className="form-label">Text File (.txt)</label>
173
+ <div
174
+ className={`dropzone ${selectedFile ? '' : ''}`}
175
+ onClick={() => fileInputRef.current?.click()}
176
+ onDrop={handleFileDrop}
177
+ onDragOver={(e) => e.preventDefault()}
178
+ >
179
+ <svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
180
+ <path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
181
+ </svg>
182
+ <p className="dropzone-text">
183
+ Drag and drop a <strong>.txt</strong> file here, or click to browse
184
+ </p>
185
+ <p className="dropzone-hint">Maximum file size: 10 MB</p>
186
+ </div>
187
+ <input
188
+ ref={fileInputRef}
189
+ type="file"
190
+ className="file-input"
191
+ accept=".txt"
192
+ onChange={handleFileDrop}
193
+ />
194
+
195
+ {selectedFile && (
196
+ <div className="file-selected">
197
+ <div className="file-icon">
198
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
199
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
200
+ <polyline points="14 2 14 8 20 8" />
201
+ <line x1="16" y1="13" x2="8" y2="13" />
202
+ <line x1="16" y1="17" x2="8" y2="17" />
203
+ </svg>
204
+ </div>
205
+ <div className="file-info">
206
+ <div className="file-name">{selectedFile.name}</div>
207
+ <div className="file-size">{formatFileSize(selectedFile.size)}</div>
208
+ </div>
209
+ <button
210
+ className="file-remove"
211
+ onClick={(e) => {
212
+ e.stopPropagation()
213
+ setSelectedFile(null)
214
+ }}
215
+ >
216
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
217
+ <line x1="18" y1="6" x2="6" y2="18" />
218
+ <line x1="6" y1="6" x2="18" y2="18" />
219
+ </svg>
220
+ </button>
221
+ </div>
222
+ )}
223
+ </div>
224
+ </div>
225
+
226
+ <div className="submit-section">
227
+ <button
228
+ className="btn btn-primary btn-lg"
229
+ onClick={handleSubmit}
230
+ disabled={loading}
231
+ >
232
+ {loading ? (
233
+ <>
234
+ <span className="loading-spinner" style={{ width: 16, height: 16 }}></span>
235
+ Processing...
236
+ </>
237
+ ) : (
238
+ <>
239
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
240
+ <path d="M22 2L11 13" />
241
+ <path d="M22 2L15 22l-4-9-9-4L22 2z" />
242
+ </svg>
243
+ Generate Summary
244
+ </>
245
+ )}
246
+ </button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ {/* Error display */}
252
+ {error && (
253
+ <div className="response-section fade-in">
254
+ <div className="response-card" style={{ borderColor: 'var(--color-danger-fg)' }}>
255
+ <div className="response-header" style={{ borderColor: 'var(--color-danger-fg)' }}>
256
+ <div className="response-title" style={{ color: 'var(--color-danger-fg)' }}>
257
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
258
+ <circle cx="12" cy="12" r="10" />
259
+ <line x1="12" y1="8" x2="12" y2="12" />
260
+ <line x1="12" y1="16" x2="12.01" y2="16" />
261
+ </svg>
262
+ Error
263
+ </div>
264
+ </div>
265
+ <div className="response-body">
266
+ <p className="response-text" style={{ color: 'var(--color-danger-fg)' }}>{error}</p>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ )}
271
+
272
+ {/* Response display */}
273
+ {response && (
274
+ <div className="response-section fade-in">
275
+ <div className="response-card">
276
+ <div className="response-header">
277
+ <div className="response-title">
278
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
279
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
280
+ <polyline points="14 2 14 8 20 8" />
281
+ <line x1="16" y1="13" x2="8" y2="13" />
282
+ <line x1="16" y1="17" x2="8" y2="17" />
283
+ </svg>
284
+ Summary
285
+ </div>
286
+ <span className="response-badge">{response.source_type}</span>
287
+ </div>
288
+ <div className="response-body">
289
+ <p className="response-text">{response.summary}</p>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ )}
294
+ </div>
295
+ </div>
296
+ </main>
297
+
298
+ <footer className="footer">
299
+ <p>Précis © 2026 · Built with ♥ · <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Documentation</a></p>
300
+ </footer>
301
+ </>
302
+ )
303
+ }
304
+
305
+ export default App
frontend/src/assets/react.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* GitHub-y colours for now */
3
+ --color-canvas-default: #0d1117;
4
+ --color-canvas-subtle: #161b22;
5
+ --color-canvas-inset: #010409;
6
+ --color-border-default: #30363d;
7
+ --color-border-muted: #21262d;
8
+
9
+ --color-fg-default: #e6edf3;
10
+ --color-fg-muted: #7d8590;
11
+ --color-fg-subtle: #6e7681;
12
+
13
+ --color-accent-fg: #58a6ff;
14
+ --color-accent-emphasis: #1f6feb;
15
+ --color-success-fg: #3fb950;
16
+ --color-attention-fg: #d29922;
17
+ --color-danger-fg: #f85149;
18
+
19
+ --color-btn-bg: #21262d;
20
+ --color-btn-border: #363b42;
21
+ --color-btn-hover-bg: #30363d;
22
+ --color-btn-primary-bg: #238636;
23
+ --color-btn-primary-hover-bg: #2ea043;
24
+
25
+ /* Typography */
26
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
27
+ --font-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
28
+
29
+ /* Spacing */
30
+ --spacing-1: 4px;
31
+ --spacing-2: 8px;
32
+ --spacing-3: 16px;
33
+ --spacing-4: 24px;
34
+ --spacing-5: 32px;
35
+ --spacing-6: 48px;
36
+
37
+ /* Border radius */
38
+ --radius-1: 6px;
39
+ --radius-2: 12px;
40
+ --radius-full: 9999px;
41
+
42
+ /* Shadows */
43
+ --shadow-sm: 0 1px 0 rgba(27, 31, 35, 0.04);
44
+ --shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15);
45
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.25);
46
+
47
+ /* Transitions */
48
+ --transition-fast: 80ms cubic-bezier(0.33, 1, 0.68, 1);
49
+ --transition-normal: 150ms cubic-bezier(0.33, 1, 0.68, 1);
50
+ }
51
+
52
+ * {
53
+ box-sizing: border-box;
54
+ margin: 0;
55
+ padding: 0;
56
+ }
57
+
58
+ body {
59
+ font-family: var(--font-family);
60
+ background-color: var(--color-canvas-default);
61
+ color: var(--color-fg-default);
62
+ line-height: 1.5;
63
+ min-height: 100vh;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+
67
+ #root {
68
+ min-height: 100vh;
69
+ display: flex;
70
+ flex-direction: column;
71
+ }
72
+
73
+ /* Utility classes */
74
+ .container {
75
+ max-width: 1280px;
76
+ margin: 0 auto;
77
+ padding: 0 var(--spacing-3);
78
+ }
79
+
80
+ /* Button styles */
81
+ .btn {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ gap: var(--spacing-2);
86
+ padding: 5px 16px;
87
+ font-size: 14px;
88
+ font-weight: 500;
89
+ line-height: 20px;
90
+ white-space: nowrap;
91
+ vertical-align: middle;
92
+ cursor: pointer;
93
+ user-select: none;
94
+ border: 1px solid var(--color-btn-border);
95
+ border-radius: var(--radius-1);
96
+ background-color: var(--color-btn-bg);
97
+ color: var(--color-fg-default);
98
+ transition: background-color var(--transition-fast), border-color var(--transition-fast);
99
+ }
100
+
101
+ .btn:hover {
102
+ background-color: var(--color-btn-hover-bg);
103
+ border-color: var(--color-border-default);
104
+ }
105
+
106
+ .btn:focus {
107
+ outline: none;
108
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.3);
109
+ }
110
+
111
+ .btn-primary {
112
+ background-color: var(--color-btn-primary-bg);
113
+ border-color: transparent;
114
+ color: #ffffff;
115
+ }
116
+
117
+ .btn-primary:hover {
118
+ background-color: var(--color-btn-primary-hover-bg);
119
+ }
120
+
121
+ .btn-lg {
122
+ padding: 10px 24px;
123
+ font-size: 16px;
124
+ line-height: 24px;
125
+ }
126
+
127
+ /* Card styles */
128
+ .card {
129
+ background-color: var(--color-canvas-subtle);
130
+ border: 1px solid var(--color-border-default);
131
+ border-radius: var(--radius-1);
132
+ padding: var(--spacing-4);
133
+ }
134
+
135
+ /* Input styles */
136
+ .input {
137
+ width: 100%;
138
+ padding: 5px 12px;
139
+ font-size: 14px;
140
+ line-height: 20px;
141
+ color: var(--color-fg-default);
142
+ background-color: var(--color-canvas-default);
143
+ border: 1px solid var(--color-border-default);
144
+ border-radius: var(--radius-1);
145
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
146
+ }
147
+
148
+ .input:focus {
149
+ outline: none;
150
+ border-color: var(--color-accent-emphasis);
151
+ box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
152
+ }
153
+
154
+ .input::placeholder {
155
+ color: var(--color-fg-subtle);
156
+ }
157
+
158
+ /* Textarea styles */
159
+ .textarea {
160
+ width: 100%;
161
+ min-height: 150px;
162
+ padding: 12px;
163
+ font-size: 14px;
164
+ font-family: var(--font-mono);
165
+ line-height: 1.6;
166
+ color: var(--color-fg-default);
167
+ background-color: var(--color-canvas-default);
168
+ border: 1px solid var(--color-border-default);
169
+ border-radius: var(--radius-1);
170
+ resize: vertical;
171
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
172
+ }
173
+
174
+ .textarea:focus {
175
+ outline: none;
176
+ border-color: var(--color-accent-emphasis);
177
+ box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
178
+ }
179
+
180
+ /* File drop zone */
181
+ .dropzone {
182
+ display: flex;
183
+ flex-direction: column;
184
+ align-items: center;
185
+ justify-content: center;
186
+ gap: var(--spacing-3);
187
+ padding: var(--spacing-6);
188
+ border: 2px dashed var(--color-border-default);
189
+ border-radius: var(--radius-2);
190
+ background-color: var(--color-canvas-inset);
191
+ cursor: pointer;
192
+ transition: border-color var(--transition-normal), background-color var(--transition-normal);
193
+ }
194
+
195
+ .dropzone:hover,
196
+ .dropzone.active {
197
+ border-color: var(--color-accent-fg);
198
+ background-color: rgba(88, 166, 255, 0.05);
199
+ }
200
+
201
+ .dropzone-icon {
202
+ width: 48px;
203
+ height: 48px;
204
+ color: var(--color-fg-muted);
205
+ }
206
+
207
+ .dropzone-text {
208
+ font-size: 16px;
209
+ color: var(--color-fg-muted);
210
+ text-align: center;
211
+ }
212
+
213
+ .dropzone-hint {
214
+ font-size: 12px;
215
+ color: var(--color-fg-subtle);
216
+ }
217
+
218
+ /* Tabs */
219
+ .tabs {
220
+ display: flex;
221
+ border-bottom: 1px solid var(--color-border-default);
222
+ margin-bottom: var(--spacing-4);
223
+ }
224
+
225
+ .tab {
226
+ padding: var(--spacing-2) var(--spacing-3);
227
+ font-size: 14px;
228
+ font-weight: 500;
229
+ color: var(--color-fg-muted);
230
+ background: transparent;
231
+ border: none;
232
+ border-bottom: 2px solid transparent;
233
+ cursor: pointer;
234
+ transition: color var(--transition-fast), border-color var(--transition-fast);
235
+ margin-bottom: -1px;
236
+ }
237
+
238
+ .tab:hover {
239
+ color: var(--color-fg-default);
240
+ }
241
+
242
+ .tab.active {
243
+ color: var(--color-fg-default);
244
+ border-bottom-color: var(--color-accent-fg);
245
+ }
246
+
247
+ /* Animations */
248
+ @keyframes fadeIn {
249
+ from {
250
+ opacity: 0;
251
+ transform: translateY(8px);
252
+ }
253
+ to {
254
+ opacity: 1;
255
+ transform: translateY(0);
256
+ }
257
+ }
258
+
259
+ @keyframes pulse {
260
+ 0%, 100% {
261
+ opacity: 1;
262
+ }
263
+ 50% {
264
+ opacity: 0.5;
265
+ }
266
+ }
267
+
268
+ .fade-in {
269
+ animation: fadeIn 0.3s ease-out forwards;
270
+ }
271
+
272
+ .pulse {
273
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
274
+ }
275
+
276
+ /* Scrollbar */
277
+ ::-webkit-scrollbar {
278
+ width: 8px;
279
+ height: 8px;
280
+ }
281
+
282
+ ::-webkit-scrollbar-track {
283
+ background: var(--color-canvas-default);
284
+ }
285
+
286
+ ::-webkit-scrollbar-thumb {
287
+ background: var(--color-border-default);
288
+ border-radius: var(--radius-full);
289
+ }
290
+
291
+ ::-webkit-scrollbar-thumb:hover {
292
+ background: var(--color-fg-subtle);
293
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })