diff --git a/packages/attache/README.md b/packages/attache/README.md index 25fa4c3..90b4588 100644 --- a/packages/attache/README.md +++ b/packages/attache/README.md @@ -1,4 +1,4 @@ -# attaché 💼 +# 💼 Attaché Stores files in folders, with style. diff --git a/packages/attache/package.json b/packages/attache/package.json index 0172c4f..2dd607b 100644 --- a/packages/attache/package.json +++ b/packages/attache/package.json @@ -1,10 +1,11 @@ { "name": "attache", - "module": "index.ts", + "module": "src/server.tsx", "type": "module", "private": true, "scripts": { - "dev": "bun run --watch src/server.ts" + "dev": "bun run --hot src/server.tsx", + "start": "bun run src/server.tsx" }, "devDependencies": { "@types/bun": "latest" @@ -14,6 +15,7 @@ }, "dependencies": { "hono": "^4.8.0", - "nanoid": "^5.1.5" + "nanoid": "^5.1.5", + "@workshop/shared": "workspace:*" } } diff --git a/packages/attache/src/server.ts b/packages/attache/src/server.ts deleted file mode 100644 index b41bf0c..0000000 --- a/packages/attache/src/server.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Hono } from 'hono' -import { serveStatic } from 'hono/bun' -import { basename, join } from 'path' -import { mkdir, readdir } from "node:fs/promises" -import { nanoid } from 'nanoid' - -const app = new Hono() -const PROJECTS_DIR = './projects' -const PROJECTS_FILE = join(PROJECTS_DIR, 'projects.json') - -type Project = { - id: string - name: string - createdAt: string -} - -app.get('/projects', async c => { - const projects = await loadProjects() - return c.json(projects) -}) - -app.post('/projects', async c => { - const { name } = await c.req.json() - const projects = await loadProjects() - - if (projects.some(p => p.name === name)) - return c.json({ error: "A project with this name already exists" }, 409) - - const id = nanoid(6) - projects.push({ id, name, createdAt: new Date().toISOString() }) - await mkdir(join(PROJECTS_DIR, `project_${id}`), { recursive: true }) - await saveProjects(projects) - return c.json(projects[projects.length - 1]) -}) - -app.patch('/projects/:id', async c => { - const id = c.req.param('id') - const { name } = await c.req.json() - const projects = await loadProjects() - const proj = projects.find(p => p.id === id) - if (!proj) return c.json({ error: 'Not found' }, 404) - proj.name = name - await saveProjects(projects) - return c.json(proj) -}) - -app.get('/project/:id', async c => { - const id = c.req.param('id') - const folder = join(PROJECTS_DIR, `project_${id}`) - try { - return c.json(await readdir(folder, { withFileTypes: true })) - } catch { - return c.json({ error: 'Not found' }, 404) - } -}) - -app.post('/project/:id/upload', async c => { - const id = c.req.param('id') - - if (id !== basename(id)) - return c.json({ error: 'Invalid project id' }, 400) - - const folder = join(PROJECTS_DIR, `project_${id}`) - try { - await mkdir(folder, { recursive: true }) - - const form = await c.req.formData() - const file = form.get('file') - if (!(file instanceof File)) - return c.json({ error: 'Invalid file' }, 400) - - - // strip any directory parts from the uploaded filename - const filename = basename(file.name) - const filepath = join(folder, filename) - await Bun.write(filepath, await file.arrayBuffer()) - - return c.json({ status: 'ok' }) - } catch (err) { - console.error('Upload error:', err) - return c.json({ error: 'Upload failed' }, 500) - } -}) - -app.get('/project/:id/file/:filename', async c => { - const { id, filename } = c.req.param() - const path = join(PROJECTS_DIR, `project_${id}`, filename) - const file = Bun.file(path) - if (!(await file.exists())) return c.json({ error: 'Not found' }, 404) - - return new Response(file, { - headers: { - 'Content-Type': file.type || 'application/octet-stream', - }, - }) -}) - -app.delete('/project/:id/file/:filename', async c => { - const { id, filename } = c.req.param() - const path = join(PROJECTS_DIR, `project_${id}`, filename) - try { - await Bun.file(path).delete() - return c.text('Deleted') - } catch { - return c.json({ error: 'Not found' }, 404) - } -}) - -app.use('/public/*', serveStatic({ root: './' })) - -async function loadProjects(): Promise { - try { - const file = Bun.file(PROJECTS_FILE) - const text = await file.text() - return JSON.parse(text) - } catch { - return [] - } -} - -async function saveProjects(projects: Project[]) { - await Bun.write(PROJECTS_FILE, JSON.stringify(projects, null, 2)) -} - -export default { - port: 3000, - fetch: app.fetch -} \ No newline at end of file diff --git a/packages/attache/src/server.tsx b/packages/attache/src/server.tsx new file mode 100644 index 0000000..a1fff73 --- /dev/null +++ b/packages/attache/src/server.tsx @@ -0,0 +1,367 @@ +import { Hono } from 'hono' +import { serveStatic } from 'hono/bun' +import { basename, join } from 'path' +import { mkdir, readdir } from 'node:fs/promises' +import { nanoid } from 'nanoid' +import KV, { type Keys } from '@workshop/shared/kv' +import { Dirent } from 'node:fs' +import { html } from 'hono/html' + +declare global { + interface Window { + location: Location + } + var location: Location +} + +type Project = { + id: string + name: string + createdAt: string +} + +declare module "@workshop/shared/kv" { + interface Keys { + projects: Project[] + } +} + + +const app = new Hono() +const PROJECTS_DIR = './projects' + +app.get('/projects', async c => { + const projects = await loadProjects() + return c.html( + + + + ) +}) + +app.get('/project/:id', async c => { + const id = c.req.param('id') + const { project, files } = await loadProject(id, { files: true }) + if (!project) return c.json({ error: 'Not found' }, 404) + return c.html( + + + + ) +}) + +app.get('/new', async c => { + return c.html( + + + + ) +}) + +const api = new Hono() + +api.get('/projects', async c => { + const projects = await loadProjects() + return c.json(projects) +}) + +api.post('/projects', async c => { + const form = await c.req.formData() + const name = form.get('name') + if (!name) return c.json({ error: "Name is required" }, 400) + if (typeof name !== 'string') return c.json({ error: "Name must be a string" }, 400) + + const projects = await loadProjects() + + if (projects.some(p => p.name === name)) + return c.json({ error: "A project with this name already exists" }, 409) + + const id = nanoid(6) + projects.push({ id, name, createdAt: new Date().toISOString() }) + await mkdir(join(PROJECTS_DIR, `project_${id}`), { recursive: true }) + await saveProjects(projects) + return c.json(projects[projects.length - 1]) +}) + +api.patch('/projects/:id', async c => { + const id = c.req.param('id') + const { name } = await c.req.json() + const { project } = await loadProject(id) + if (!project) return c.json({ error: 'Not found' }, 404) + project.name = name + await saveProject(project) + return c.json(project) +}) + +api.get('/project/:id', async c => { + const id = c.req.param('id') + try { + const { project, files } = await loadProject(id, { files: true }) + return c.json({ files, project }) + } catch { + return c.json({ error: 'Not found' }, 404) + } +}) + +api.post('/project/:id/upload', async c => { + const id = c.req.param('id') + + if (id !== basename(id)) + return c.json({ error: 'Invalid project id' }, 400) + + const folder = join(PROJECTS_DIR, `project_${id}`) + try { + await mkdir(folder, { recursive: true }) + + const form = await c.req.formData() + const file = form.get('file') + if (!(file instanceof File)) + return c.json({ error: 'Invalid file' }, 400) + + + // strip any directory parts from the uploaded filename + const filename = basename(file.name) + const filepath = join(folder, filename) + await Bun.write(filepath, await file.arrayBuffer()) + + return c.json({ status: 'ok' }) + } catch (err) { + console.error('Upload error:', err) + return c.json({ error: 'Upload failed' }, 500) + } +}) + +api.get('/project/:id/file/:filename', async c => { + const { id, filename } = c.req.param() + const path = join(PROJECTS_DIR, `project_${id}`, filename) + const file = Bun.file(path) + if (!(await file.exists())) return c.json({ error: 'Not found' }, 404) + + return new Response(file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + }, + }) +}) + +api.delete('/project/:id/file/:filename', async c => { + const { id, filename } = c.req.param() + const path = join(PROJECTS_DIR, `project_${id}`, filename) + try { + await Bun.file(path).delete() + return c.text('Deleted') + } catch { + return c.json({ error: 'Not found' }, 404) + } +}) + +api.delete("/project/:id", async c => { + const id = c.req.param("id") + const { project } = await loadProject(id) + if (!project) return c.json({ error: "Not found" }, 404) + + const projects = await loadProjects() + const index = projects.findIndex(p => p.id === id) + if (index !== -1) { + projects.splice(index, 1) + await saveProjects(projects) + } + return c.json({ status: "ok" }) +}) + +app.route('/api', api) +app.use('/public/*', serveStatic({ root: './' })) + +async function loadProject(id: string, { files }: { files?: boolean } = {}): Promise<{ project: Project | null, files?: string[] }> { + const projects = await loadProjects() + const project = projects.find(p => p.id === id) + if (!project) return { project: null } + + if (files) { + const folder = join(PROJECTS_DIR, `project_${id}`) + const files = (await readdir(folder, { withFileTypes: true })).filter(f => f.isFile()).map(f => f.name) + return { project, files } + } + + return { project } +} + +async function loadProjects(): Promise { + return (await KV.get("projects")) ?? [] +} + +async function saveProject(project: Project) { + const projects = await loadProjects() + const index = projects.findIndex(p => p.id === project.id) + if (index !== -1) { + projects[index] = project + } else { + projects.push(project) + } + await saveProjects(projects) +} + +async function saveProjects(projects: Project[]) { + await KV.set("projects", projects) +} + +const Projects = ({ projects }: { projects: Project[] }) => { + return ( +
+

Projects

+ +
+ ) +} + +const Project = ({ project, files }: { project: Project, files: string[] }) => { + return ( +
+
+

{project.name}

+ +
+
+

Files

+
    + {files.map(file => ( +
  • + {file} +
    + + Download + + +
    +
  • + ))} +
+
+ {html` + + `} +
+ ) +} + +const NewProject = () => { + return ( +
+

New Project

+
+
+ + +
+
+ {html` + + `} +
+ ) +} + +const Layout = ({ children, title }: { children: any, title: string }) => { + return ( + + + {title} - 💼 Attaché + + + + +
+ +
{children}
+
+ + + ) +} + +export default { + port: 3000, + fetch: app.fetch +} \ No newline at end of file diff --git a/packages/attache/tsconfig.json b/packages/attache/tsconfig.json index 9c62f74..8da9238 100644 --- a/packages/attache/tsconfig.json +++ b/packages/attache/tsconfig.json @@ -1,11 +1,12 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", - "module": "ESNext", + "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", "allowJs": true, // Bundler mode @@ -19,10 +20,16 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } } }