diff --git a/main.ts b/main.ts index 33adef3..ed15b90 100644 --- a/main.ts +++ b/main.ts @@ -7,28 +7,27 @@ console.log("----------------------------------\n\n") const run = async (cmd: string[]) => { const commandText = cmd.join(" ") - const proc = spawn(cmd, { stdout: "inherit", stderr: "inherit" }) - console.log(`ðŸŠī "${commandText}" spawned with PID ${proc.pid}`) + const proc = spawn(cmd, { + stdout: "inherit", + stderr: "inherit", + }) - try { - const status = await proc.exited + const status = await proc.exited + + if (status !== 0) { + throw new Error(`Process "${commandText}" failed with exit code ${status}`) + } else { console.log(`👋 Process ${commandText}(PID ${proc.pid}) exited with code ${status}`) - - if (status !== 0) { - throw new Error(`Process "${commandText}" failed with exit code ${status}`) - } - - return status - } catch (err) { - console.error(`ðŸ’Ĩ Error waiting for "${commandText}" exit:`, err) - throw err } + + return status } try { - await Promise.all([run(["bun", "bot:discord"]), run(["bun", "http"])]) + await Promise.all([run(["bun", "run", "--filter=@workshop/http", "start"]), run(["bun", "bot:discord"])]) console.log("✅ All processes completed successfully") } catch (error) { + console.log(`🌭`, error.message) console.error("❌ One or more processes failed:", error) process.exit(1) } diff --git a/packages/http/README.md b/packages/http/README.md index f99c128..b710186 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -1,3 +1,9 @@ -# Nano Remix (BETTER NAME NEEDED) +# http -- You'll want to add `.nano-remix` to your `.gitignore` file. +A proxy server that will start all subdomain servers and a proxy server to route requests to the correct subdomain based on the URL. + +## How to setup a subdomain server + +1. Create a new package in the `packages` directory. +2. Add a `serve-subdomain` script to the `package.json` file of the new package. +3. It uses the directory name of the package to serve the subdomain. diff --git a/packages/http/src/components/createReminder.tsx b/packages/http/src/components/createReminder.tsx deleted file mode 100644 index e8fb833..0000000 --- a/packages/http/src/components/createReminder.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Form } from "@workshop/nano-remix" -import { users } from "@workshop/shared/reminders" - -type Props = { - loading: boolean - success: boolean - error?: string -} -export const CreateReminder = (props: Props) => { - return ( -
-

Create a Reminder

-
- - {props.error && ( -
{props.error}
- )} - {props.success && ( -
- Reminder created successfully! -
- )} - -
- - -
- -
- - -
- -
- - -
- - -
-
- ) -} diff --git a/packages/http/src/index.css b/packages/http/src/index.css deleted file mode 100644 index a461c50..0000000 --- a/packages/http/src/index.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; \ No newline at end of file diff --git a/packages/http/src/orchestrator.ts b/packages/http/src/orchestrator.ts new file mode 100644 index 0000000..f181007 --- /dev/null +++ b/packages/http/src/orchestrator.ts @@ -0,0 +1,70 @@ +import { readdir } from "node:fs/promises" +import { basename, join } from "node:path" + +export const startSubdomainServers = async () => { + const portMap: Record = {} + + try { + const packageInfo = await subdomainPackageInfo() + let currentPort = 3001 + + const processes = packageInfo.map((info) => { + const port = currentPort++ + portMap[info.dirName] = port + + return run(["bun", "run", `--filter=${info.packageName}`, "serve-subdomain"], { + env: { PORT: port.toString() }, + }) + }) + + Promise.all(processes).catch((err) => { + console.log(`❌ Error starting subdomain servers:`, err) + process.exit(1) + }) + } catch (error) { + console.error("❌ Error starting subdomain servers:", error) + process.exit(1) + } + + return portMap +} + +export const subdomainPackageInfo = async () => { + const packagesDir = join(import.meta.dir, "../../") + const packages = await readdir(packagesDir) + const packagePaths: { packageName: string; dirName: string }[] = [] + + for (const pkg of packages) { + const packagePath = join(packagesDir, pkg) + const packageJsonPath = join(packagePath, "package.json") + const hasPackageJson = await Bun.file(packageJsonPath).exists() + if (!hasPackageJson) continue + + const packageJson = await Bun.file(packageJsonPath).json() + + if (packageJson.scripts?.["serve-subdomain"]) { + packagePaths.push({ packageName: packageJson.name, dirName: basename(pkg) }) + } + } + + return packagePaths +} + +const run = async (cmd: string[], options: { env?: Record } = {}) => { + const commandText = cmd.join(" ") + const proc = Bun.spawn(cmd, { + stdout: "inherit", + stderr: "inherit", + env: { ...process.env, ...options.env }, + }) + + const status = await proc.exited + + if (status !== 0) { + throw new Error(`Process "${commandText}" failed with exit code ${status}`) + } else { + console.log(`👋 Process ${commandText}(PID ${proc.pid}) exited with code ${status}`) + } + + return status +} diff --git a/packages/http/src/routes/evals.tsx b/packages/http/src/routes/evals.tsx deleted file mode 100644 index ab81ffb..0000000 --- a/packages/http/src/routes/evals.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import kv from "@workshop/shared/kv" -import { type Head, type LoaderProps } from "@workshop/nano-remix" -import "../../index.css" - -export const head: Head = { - title: "Evals", -} - -export const loader = async (_req: Request) => { - const evaluations = await kv.get("evaluations", []) - return { evaluations } -} - -export default (props: LoaderProps) => { - return ( -
-

Evals

-
-
{JSON.stringify(props.evaluations, null, 2)}
-
-
- ) -} diff --git a/packages/http/src/routes/index.tsx b/packages/http/src/routes/index.tsx new file mode 100644 index 0000000..1fdc3a4 --- /dev/null +++ b/packages/http/src/routes/index.tsx @@ -0,0 +1,23 @@ +import { subdomainPackageInfo } from "@/orchestrator" +import type { LoaderProps } from "@workshop/nano-remix" + +export const loader = async (_req: Request) => { + const packagePaths = await subdomainPackageInfo() + return { packagePaths } +} + +export default function Index({ packagePaths }: LoaderProps) { + const host = new URL(import.meta.url).host + return ( +
+

Subdomain Servers

+ +
+ ) +} diff --git a/packages/http/src/routes/reminders.tsx b/packages/http/src/routes/reminders.tsx deleted file mode 100644 index 28655bd..0000000 --- a/packages/http/src/routes/reminders.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import KV from "@workshop/shared/kv" -import { Form, type Head, type LoaderProps, useAction } from "@workshop/nano-remix" -import { addReminder, deleteReminder, type Reminder } from "@workshop/shared/reminders" -import { CreateReminder } from "@/components/createReminder" - -export const head: Head = { - title: "Reminders", -} - -export const loader = async (req: Request) => { - const reminders = await KV.get("reminders", []) - return { reminders } -} - -export const action = async (req: Request) => { - const formData = await req.formData() - const action = formData.get("_action") as string - - // Handle delete action - if (action === "delete") { - const id = formData.get("id") as string - if (!id) { - return { error: "Reminder ID is required" } - } - - try { - await deleteReminder(id) - return { success: true, message: "Reminder deleted successfully" } - } catch (error) { - return { error: `Failed to delete reminder: ${error}` } - } - } else if (action === "create") { - // Handle create action (default) - const title = formData.get("title") as string - const dueDate = formData.get("dueDate") as string - const assignee = (formData.get("assignee") as string) || undefined - - if (!title) { - return { error: "Title is required" } - } - - if (!dueDate) { - return { error: "Due date is required" } - } - - try { - const newReminder = await addReminder(title, dueDate, assignee as any) - return { success: true, newReminder } - } catch (error) { - return { error: `Failed to create reminder: ${error}` } - } - } -} - -export default (props: LoaderProps) => { - const { data, loading, error } = useAction() - - return ( -
-

Reminders

-
-
-

Current Reminders

- -
-
- -
-
-
- ) -} - -const Reminders = ({ reminders }: { reminders: Reminder[] }) => { - return ( - <> - {reminders.length === 0 ? ( -

No reminders found.

- ) : ( -
    - {reminders.map((reminder) => ( -
  • -
    -
    -
    {reminder.title}
    -
    - Due: {new Date(reminder.dueDate).toLocaleString()} - {reminder.assignee && ( - - {reminder.assignee} - - )} -
    {reminder.status}
    -
    -
    -
    - - - -
    -
    -
  • - ))} -
- )} - - ) -} diff --git a/packages/http/src/server.tsx b/packages/http/src/server.tsx index 5221f32..cdcea4b 100644 --- a/packages/http/src/server.tsx +++ b/packages/http/src/server.tsx @@ -1,25 +1,47 @@ -import { serve } from "bun" +import { startSubdomainServers } from "@/orchestrator" import { nanoRemix } from "@workshop/nano-remix" +import { serve } from "bun" import { join } from "node:path" -type StartOptions = { - routesDir?: string -} +const portMap = await startSubdomainServers() -function startServer(opts: StartOptions) { - const server = serve({ - routes: { - "/*": (req) => nanoRemix(req, opts), - }, +const server = serve({ + port: 3000, + fetch: async (req) => { + const url = new URL(req.url) + const hostname = url.hostname - development: process.env.NODE_ENV !== "production" && { hmr: true, console: true }, - }) + const subdomain = hostname.split(".")[0] - console.log(`ðŸĪ– Server running at ${server.url}`) -} + const targetPort = subdomain && portMap[subdomain] + if (!targetPort) { + const routePath = join(import.meta.dir, "routes") + return nanoRemix(req, { routePath }) + } -if (import.meta.main) { - startServer({ routesDir: join(import.meta.dir, "routes") }) -} + try { + const target = `http://127.0.0.1:${targetPort}${url.pathname}${url.search}` + const upstream = await fetch(target, req) -export { startServer } + return new Response(upstream.body, { + status: upstream.status, + headers: new Headers(upstream.headers), + }) + } catch (error) { + console.error(`Error forwarding request to subdomain "${subdomain}":`, error) + return new Response(`Failed to forward request to "${subdomain}"`, { status: 500 }) + } + }, +}) + +console.log(`${server.url}`) +const subdomainEntries = Object.entries(portMap) +subdomainEntries.forEach(([subdomain, port], index) => { + const subdomainUrl = new URL(server.url) + subdomainUrl.hostname = `${subdomain}.${subdomainUrl.hostname}` + subdomainUrl.port = port.toString() + const isLast = index === subdomainEntries.length - 1 + const prefix = isLast ? "└─" : "├─" + console.log(`${prefix} ${subdomainUrl}`) +}) +console.log("") diff --git a/packages/nano-remix/src/nanoRemix.ts b/packages/nano-remix/src/nanoRemix.ts index c10451e..670e846 100644 --- a/packages/nano-remix/src/nanoRemix.ts +++ b/packages/nano-remix/src/nanoRemix.ts @@ -1,6 +1,7 @@ import { renderServer } from "@/renderServer" import { buildDynamicRoute } from "./buildDynamicRout" import { join, extname } from "node:path" +import { serve } from "bun" type Options = { routesDir?: string diff --git a/packages/todo/package.json b/packages/todo/package.json index 4a4317e..39ac6be 100644 --- a/packages/todo/package.json +++ b/packages/todo/package.json @@ -4,6 +4,10 @@ "type": "module", "types": "src/main.ts", "private": true, + "scripts": { + "dev": "bun run src/server.tsx", + "serve-subdomain": "NODE_ENV=production bun run src/server.tsx" + }, "dependencies": { "@codemirror/autocomplete": "^6.18.6", "@codemirror/commands": "^6.8.1", diff --git a/packages/todo/src/autoTodoOnNewline.ts b/packages/todo/src/autoTodoOnNewline.ts index 4b7dc6e..0e750cb 100644 --- a/packages/todo/src/autoTodoOnNewline.ts +++ b/packages/todo/src/autoTodoOnNewline.ts @@ -25,13 +25,17 @@ type HandleNewlineChangeOpts = { const handleNewlineChange = (opts: HandleNewlineChangeOpts) => { if (!opts.inserted.toString().match(/\s*\n/)) return - let input = opts.inserted.toString().replace(/\n/g, "\\n").replace(/\s/g, "\\s") const insertPos = opts.fromB + opts.inserted.toString().length const prevLine = opts.update.state.doc.lineAt(insertPos - opts.inserted.toString().length) - if (prevLine.text.trim() === "") { + if (opts.inserted.toString().trim().length > 0) { + // If the inserted text is not just a newline, we don't want to auto-add a checkbox + return + } else if (prevLine.text.trim() === "") { + // If the previous line is empty, we don't want to add a checkbox return } else if (endsWith(prevLine.text, checkboxRegex)) { + // If the previous line has an empty checkbox, we don't want to add a new one const start = prevLine.from const end = insertPos opts.update.view.dispatch({ diff --git a/packages/todo/src/editor.css b/packages/todo/src/editor.css new file mode 100644 index 0000000..ff9c18c --- /dev/null +++ b/packages/todo/src/editor.css @@ -0,0 +1,144 @@ +.todo-completing { + display: inline-block; + animation: todoCompleting 0.5s ease-out forwards !important; + transform-origin: center center; +} + +.todo-completed { + text-decoration: line-through !important; + color: black !important; + opacity: 0.5; + +} + +@keyframes todoCompleting { + 0% { + opacity: 1; + text-decoration: none; + transform: translateY(0); + } + 20% { + transform: translateY(-1px); + } + 40% { + transform: translateY(1px); + text-decoration: line-through; + } + 60% { + transform: translateY(-0.5px); + } + 80% { + transform: translateY(0.5px); + } + 100% { + text-decoration: line-through; + opacity: 0.5; + transform: translateY(0); + } +} + +.todo-tag { + color: #10B981; +} + +.todo-date { + color: #10B981; +} + +.todo-time-estimate { + color: #aBb2d0; +} + +.todo-overdue .todo-date { + color: #EF4444; +} + +.todo-due-today .todo-date { + color: #F59E0B; +} + +.todo-invalid { + text-decoration-line: underline; + text-decoration-color: #EF4444; + text-decoration-style: wavy; +} + +.todo-header { + font-size: 1.25rem; + font-weight: 700; +} + +.todo-checkbox { + cursor: pointer; +} + +.todo-filtered { + background-color: greenyellow; +} + +.cm-editor { + height: 100%; + min-height: 0; /* Important for flex children */ + display: flex; + flex-direction: column; +} + +.cm-scroller { + flex: 1 1 auto; + height: 100%; +} + +/* Timer widget styles */ +.timer-running .cm-line { + opacity : 0.3; +} + +.timer-running .cm-line.cm-timer { + opacity : 1; +} + +.countdown-timer { + margin-left: 12px; + color: black; + padding: 2px 5px; + letter-spacing: 1px; + cursor: pointer; + user-select: none; + pointer-events: auto; + outline: none; +} + +.countdown-timer.running { + margin-left: 12px; + color: black; + padding: 2px 5px; + letter-spacing: 1px; +} + +/* blink finished output */ +.countdown-timer.finished { + animation: blink 1s infinite; + color: red; +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +.todo-url { + color: #3B82F6; + text-decoration: underline; + cursor: pointer; +} + +.todo-url:hover { + color: #1E40AF; +} \ No newline at end of file diff --git a/packages/todo/src/index.css b/packages/todo/src/index.css index 7125f48..a461c50 100644 --- a/packages/todo/src/index.css +++ b/packages/todo/src/index.css @@ -1,133 +1 @@ -.todo-completed { - display: inline-block; - animation: todoComplete 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards !important; -} - -.todo-completed * { - text-decoration: line-through; - color: black !important; - opacity: 0.5; - -} - -@keyframes todoComplete { - 0% { - opacity: 1; - text-decoration: none; - transform: scale(1) rotate(0deg); - } - 15% { - transform: scale(1.15) rotate(2deg); - } - 30% { - transform: scale(1.1) rotate(-1deg); - text-decoration: line-through; - } - 50% { - transform: scale(1.05) rotate(1deg); - } - 70% { - transform: scale(0.95) rotate(0deg); - } - 100% { - text-decoration: line-through; - opacity: 0.5; - transform: scale(1) rotate(0deg); - } -} - -.todo-tag { - color: #3B82F6; -} - -.todo-date { - color: #10B981; -} - -.todo-time-estimate { - color: #aBb2d0; -} - -.todo-overdue .todo-date { - color: #EF4444; -} - -.todo-due-today .todo-date { - color: #F59E0B; -} - -.todo-invalid { - text-decoration-line: underline; - text-decoration-color: #EF4444; - text-decoration-style: wavy; -} - -.todo-header { - font-size: 1.25rem; - font-weight: 700; -} - -.todo-checkbox { - cursor: pointer; -} - -.todo-filtered { - background-color: greenyellow; -} - -.cm-editor { - height: 100%; - min-height: 0; /* Important for flex children */ - display: flex; - flex-direction: column; -} - -.cm-scroller { - flex: 1 1 auto; - height: 100%; -} - -/* Timer widget styles */ -.timer-running .cm-line { - opacity : 0.3; -} - -.timer-running .cm-line.cm-timer { - opacity : 1; -} - -.countdown-timer { - margin-left: 12px; - color: black; - padding: 2px 5px; - letter-spacing: 1px; - cursor: pointer; - user-select: none; - pointer-events: auto; - outline: none; -} - -.countdown-timer.running { - margin-left: 12px; - color: black; - padding: 2px 5px; - letter-spacing: 1px; -} - -/* blink finished output */ -.countdown-timer.finished { - animation: blink 1s infinite; - color: red; -} - -@keyframes blink { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } -} \ No newline at end of file +@import "tailwindcss"; \ No newline at end of file diff --git a/packages/http/src/routes/todos/index.tsx b/packages/todo/src/routes/index.tsx similarity index 97% rename from packages/http/src/routes/todos/index.tsx rename to packages/todo/src/routes/index.tsx index 4c2d17e..64d73c9 100644 --- a/packages/http/src/routes/todos/index.tsx +++ b/packages/todo/src/routes/index.tsx @@ -1,6 +1,6 @@ import KV from "@workshop/shared/kv" import { type Head, type LoaderProps } from "@workshop/nano-remix" -import "../../index.css" +import "../index.css" export const head: Head = { title: "Todos", diff --git a/packages/http/src/routes/todos/[id].tsx b/packages/todo/src/routes/todos/[id].tsx similarity index 100% rename from packages/http/src/routes/todos/[id].tsx rename to packages/todo/src/routes/todos/[id].tsx diff --git a/packages/todo/src/server.tsx b/packages/todo/src/server.tsx new file mode 100644 index 0000000..81e2c21 --- /dev/null +++ b/packages/todo/src/server.tsx @@ -0,0 +1,20 @@ +import { serve } from "bun" +import { nanoRemix } from "@workshop/nano-remix" +import { join } from "node:path" + +const server = serve({ + port: parseInt(process.env.PORT || "3000"), + routes: { + "/*": (req) => { + const routePath = join(import.meta.dir, "routes") + return nanoRemix(req, { routePath }) + }, + }, + + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}) + +console.log(`Server running at ${server.url}`) diff --git a/packages/todo/src/todo.ts b/packages/todo/src/todo.ts index afca489..907acf7 100644 --- a/packages/todo/src/todo.ts +++ b/packages/todo/src/todo.ts @@ -77,6 +77,7 @@ type TodoNode = { content: string } & ( | { type: "date"; parsed: DateTime } | { type: "tag" } | { type: "time-estimate"; seconds: number } + | { type: "url"; url: string } ) const parseError = (line: string, offset: number, message?: string): string => { @@ -123,6 +124,7 @@ const parseTodo = (line: string): Todo | undefined => { const isTag = line.slice(offset).startsWith("#") const isDate = line.slice(offset).startsWith("@") const isTimeEstimate = line.slice(offset).match(/^[\d\.]+\w($|\s+)/) + const isUrl = line.slice(offset).match(/^https?:\/\//) let node: TodoNode | undefined if (isTag) { @@ -131,6 +133,8 @@ const parseTodo = (line: string): Todo | undefined => { node = parseDate(line, offset) } else if (isTimeEstimate) { node = parseTimeEstimate(line, offset) + } else if (isUrl) { + node = parseUrl(line, offset) } else { node = parseText(line, offset) } @@ -211,3 +215,14 @@ const parseText = (line: string, start: number) => { return node } + +const parseUrl = (line: string, start: number) => { + const urlMatch = line.slice(start).match(/^(https?:\/\/[^\s]+)/) + const content = urlMatch?.[0] + if (!content) { + return parseText(line, start) + } + + const node: TodoNode = { type: "url", content, url: content } + return node +} diff --git a/packages/todo/src/todoCompletion.ts b/packages/todo/src/todoCompletion.ts index dc0348b..ec570bd 100644 --- a/packages/todo/src/todoCompletion.ts +++ b/packages/todo/src/todoCompletion.ts @@ -1,12 +1,13 @@ -import { RangeSetBuilder } from "@codemirror/state" -import { Decoration, EditorView } from "@codemirror/view" +import { completionAnimationEffect } from "@/todoDecorations" +import { EditorView } from "@codemirror/view" export const triggerTodoCompletionEffect = async (view: EditorView, pos: number) => { - const builder = new RangeSetBuilder() - const line = view.state.doc.lineAt(pos) + console.log(`🌭 HAHAHA`, pos) - builder.add(pos, pos, Decoration.line({ class: "animate-completion" })) - builder.finish() + // Dispatch the completion animation effect + view.dispatch({ + effects: completionAnimationEffect.of({ pos, duration: 1000 }), + }) // Only play sound in browser environment (not during SSR) const { zzfx } = await import("zzfx") diff --git a/packages/todo/src/todoDecorations.ts b/packages/todo/src/todoDecorations.ts index 5904a28..dc611d7 100644 --- a/packages/todo/src/todoDecorations.ts +++ b/packages/todo/src/todoDecorations.ts @@ -4,22 +4,64 @@ import { EditorView, Decoration, ViewPlugin, ViewUpdate, WidgetType } from "@cod import { type RefObject } from "hono/jsx" import { DateTime } from "luxon" +// URL Widget for clickable URLs +class URLWidget extends WidgetType { + constructor(readonly url: string, readonly text: string) { + super() + } + + toDOM() { + const span = document.createElement("span") + span.className = "todo-url" + span.textContent = this.text + span.addEventListener("click", (e) => { + e.preventDefault() + window.open(this.url, "_blank") + }) + return span + } +} + // Effect to trigger a decoration refresh on filter change export const refreshFilterEffect = StateEffect.define() +// Effect to add temporary completion animation +export const completionAnimationEffect = StateEffect.define<{ pos: number; duration?: number }>() + export const todoDecorations = (filterRef: RefObject) => { return ViewPlugin.fromClass( class { decorations: any + completingLines: Set = new Set() + constructor(view: EditorView) { this.decorations = this.buildDecorations(view) } update(update: ViewUpdate) { + // Handle completion animation effects + for (const transaction of update.transactions) { + for (const effect of transaction.effects) { + if (effect.is(completionAnimationEffect)) { + const { pos, duration = 1000 } = effect.value + const line = update.view.state.doc.lineAt(pos) + this.completingLines.add(line.number) + + // Remove the animation after the duration + setTimeout(() => { + this.completingLines.delete(line.number) + update.view.dispatch({ effects: refreshFilterEffect.of() }) + }, duration) + } + } + } + if ( update.docChanged || update.viewportChanged || - update.transactions.some((tr) => tr.effects.some((e) => e.is(refreshFilterEffect))) + update.transactions.some((tr) => + tr.effects.some((e) => e.is(refreshFilterEffect) || e.is(completionAnimationEffect)) + ) ) { this.decorations = this.buildDecorations(update.view) } @@ -40,8 +82,13 @@ export const todoDecorations = (filterRef: RefObject) => { builder.add(line.from, line.from, Decoration.line({ attributes: { style: "display: none" } })) } + // Add completion animation if this line is animating + if (this.completingLines.has(line.number)) { + builder.add(line.from, line.from, Decoration.line({ class: "todo-completing" })) + } + if (todo.done) { - builder.add(line.from, line.to, Decoration.mark({ class: "todo-completed" })) + builder.add(line.from, line.from, Decoration.line({ class: "todo-completed" })) } if (todo.dueDate) { @@ -61,8 +108,13 @@ export const todoDecorations = (filterRef: RefObject) => { builder.add(line.from, line.from + checkboxEnd, Decoration.mark({ class: "todo-checkbox" })) } - for (const { type, start, end } of decorationsFor(todo)) { - builder.add(line.from + start, line.from + end, Decoration.mark({ class: `todo-${type}` })) + for (const { type, start, end, widget } of decorationsFor(todo)) { + if (widget) { + // Replace the text with a clickable widget + builder.add(line.from + start, line.from + end, Decoration.replace({ widget })) + } else { + builder.add(line.from + start, line.from + end, Decoration.mark({ class: `todo-${type}` })) + } } } else if (/^\s*#+\s/.test(text)) { builder.add(line.from, line.to, Decoration.mark({ class: "todo-header" })) @@ -81,14 +133,20 @@ export const todoDecorations = (filterRef: RefObject) => { } const decorationsFor = (todo: Todo) => { - const decorations: { type: string; start: number; end: number }[] = [] + const decorations: { type: string; start: number; end: number; widget?: WidgetType }[] = [] let start = 0 for (const node of todo.nodes) { const { type } = node const end = start + node.content.length - decorations.push({ type, start, end }) + if (type === "url") { + // Create a widget for clickable URLs + const urlWidget = new URLWidget(node.url, node.content) + decorations.push({ type: "url", start, end, widget: urlWidget }) + } else { + decorations.push({ type, start, end }) + } start = end } diff --git a/packages/todo/src/todoEditor.tsx b/packages/todo/src/todoEditor.tsx index 0c4f766..644a230 100644 --- a/packages/todo/src/todoEditor.tsx +++ b/packages/todo/src/todoEditor.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "hono/jsx" +import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type RefObject } from "hono/jsx" import { defaultKeymap, history, historyKeymap } from "@codemirror/commands" import { EditorState } from "@codemirror/state" import { EditorView, lineNumbers, keymap } from "@codemirror/view" -import { keymap as viewKeymap } from "@codemirror/view" // ensure keymap is imported from view if not already import { foldGutter, foldKeymap } from "@codemirror/language" import { refreshFilterEffect, todoDecorations } from "@/todoDecorations" import { autoTodoOnNewline } from "@/autoTodoOnNewline" @@ -12,13 +11,68 @@ import { dateAutocompletion } from "@/dateAutocompletion" import { todoTimer } from "@/todoTimer" import { DateTime } from "luxon" -import "./index.css" +import "./editor.css" + +// Constants +const COMPLETION_ANIMATION_DURATION = 1000 +const FILTER_PLACEHOLDER = "Filter by tag" + +const getDefaultDocument = () => { + const today = DateTime.local().toFormat("MM/dd/yyyy") + const future = DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy") + + return ` +# Today + +- [ ] Sample task with a due date @${today} +- [ ] You can use a #tag to filter todos + - [ ] A sub task! Create nested todos by indenting with +- [x] Complete a todo by pressing +- [ ] You can also set a time estimate for todos (press the play button to start) 10m + +# This week +- [ ] Another todo with a due date @${future} + +# Later +- [ ] I use later as a junk drawer for todos I don't want to forget + +`.trim() +} type TodoEditorProps = { defaultValue?: string onChange: (todos: string) => void } +const createEditorExtensions = ( + filterRef: RefObject, + filterElRef: RefObject, + setShowShortcuts: (show: boolean) => void, + onChange: (todos: string) => void +) => { + const changeListener = EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()) + } + }) + + return [ + foldGutter(), + lineNumbers(), + history(), + todoDecorations(filterRef), + changeListener, + autoTodoOnNewline, + todoKeymap(filterElRef, setShowShortcuts), + todoClickHandler, + dateAutocompletion, + todoTimer, + keymap.of(historyKeymap), + keymap.of(defaultKeymap), + keymap.of(foldKeymap), + ] +} + export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { const editorContainer = useRef(null) const editorRef = useRef(null) @@ -36,29 +90,9 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { if (editorRef.current) editorRef.current.destroy() if (!editorContainer.current) return - const changeListener = EditorView.updateListener.of((update) => { - if (update.docChanged) { - onChange(update.state.doc.toString()) - } - }) - const state = EditorState.create({ - doc: defaultValue || defaultDoc, - extensions: [ - foldGutter(), - lineNumbers(), - history(), - todoDecorations(filterRef), - changeListener, - autoTodoOnNewline, - todoKeymap(filterElRef, setShowShortcuts), - todoClickHandler, - dateAutocompletion, - todoTimer, - keymap.of(historyKeymap), - keymap.of(defaultKeymap), - viewKeymap.of(foldKeymap), - ], + doc: defaultValue || getDefaultDocument(), + extensions: createEditorExtensions(filterRef, filterElRef, setShowShortcuts, onChange), }) const view = new EditorView({ state, parent: editorContainer.current }) @@ -70,26 +104,27 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { // Handle escape key globally to close shortcuts modal useEffect(() => { - const handleKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === "Escape" && showShortcuts) { + if (!showShortcuts) return + + const handleEscape = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") { setShowShortcuts(false) e.preventDefault() } } - if (showShortcuts) { - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - } + document.addEventListener("keydown", handleEscape) + return () => document.removeEventListener("keydown", handleEscape) }, [showShortcuts]) - const filterInput = useCallback((e: KeyboardEvent) => { + const handleFilterInput = useCallback((e: KeyboardEvent) => { + const target = e.target as HTMLInputElement + if (e.key === "Enter" || e.key === "Escape") { - if (!editorRef.current) return editorRef.current?.focus() e.preventDefault() } else { - setFilter((e.target as HTMLInputElement).value) + setFilter(target.value) } }, []) @@ -98,10 +133,10 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { {showShortcuts && setShowShortcuts(false)} />} (e.target as HTMLInputElement).select()} class="p-2 border-b" /> @@ -111,43 +146,28 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => { } const KeyboardShortcuts = ({ onClose }: { onClose: () => void }) => { + const shortcuts = buildKeyBindings(undefined as any).filter((k) => !k.hidden) + return (
e.stopPropagation()}>
    - {buildKeyBindings(undefined as any) - .filter((k) => !k.hidden) - .map((binding) => ( -
  • - {binding.label} -
    - {binding.key.split("-").map((part) => ( - {part} - ))} -
    -
  • - ))} + {shortcuts.map((binding) => ( +
  • + {binding.label} +
    + {binding.key.split("-").map((part, index) => ( + + {part} + + ))} +
    +
  • + ))}
) } - -const defaultDoc = ` -# Today - -- [ ] Sample task with a due date @${DateTime.local().toFormat("MM/dd/yyyy")} -- [ ] You can use a #tag to filter todos - - [ ] A sub task! Create nested todos by indenting with -- [x] Complete a todo by pressing -- [ ] You can also set a time estimate for todos (press the play button to start) 10m - -# This week -- [ ] Another todo with a due date @${DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy")} - -# Later -- [ ] I use later as a junk drawer for todos I don't want to forget - -`.trim() diff --git a/packages/todo/src/todoKeymap.ts b/packages/todo/src/todoKeymap.ts index 298cca7..e332a54 100644 --- a/packages/todo/src/todoKeymap.ts +++ b/packages/todo/src/todoKeymap.ts @@ -125,7 +125,7 @@ const moveToDone = (view: EditorView) => { let doneHeader = todoList.find((header) => header.title === "# Done") if (!doneHeader) { - doneHeader = { title: "# Done", todos: [] } + doneHeader = { title: "\n# Done", todos: [] } todoList.push(doneHeader) } diff --git a/packages/werewolf-ui/package.json b/packages/werewolf-ui/package.json index 45f16a4..ad65c13 100644 --- a/packages/werewolf-ui/package.json +++ b/packages/werewolf-ui/package.json @@ -1,5 +1,5 @@ { - "name": "werewolfUI", + "name": "@workshop/werewolf-ui", "version": "0.1.0", "private": true, "type": "module", @@ -7,7 +7,7 @@ "module": "src/index.tsx", "scripts": { "dev": "bun --hot src/server.tsx", - "start": "NODE_ENV=production bun src/server.tsx" + "serve-subdomain": "NODE_ENV=production bun src/server.tsx" }, "prettier": { "semi": false, diff --git a/packages/werewolf-ui/src/server.tsx b/packages/werewolf-ui/src/server.tsx index cc96005..81e2c21 100644 --- a/packages/werewolf-ui/src/server.tsx +++ b/packages/werewolf-ui/src/server.tsx @@ -3,6 +3,7 @@ import { nanoRemix } from "@workshop/nano-remix" import { join } from "node:path" const server = serve({ + port: parseInt(process.env.PORT || "3000"), routes: { "/*": (req) => { const routePath = join(import.meta.dir, "routes") @@ -16,4 +17,4 @@ const server = serve({ }, }) -console.log(`🚀 Server running at ${server.url}`) +console.log(`Server running at ${server.url}`)