This commit is contained in:
Corey Johnson 2025-07-08 16:28:41 -07:00
parent 79e318f89d
commit 968422e021
24 changed files with 506 additions and 466 deletions

27
main.ts
View File

@ -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)
}

View File

@ -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.

View File

@ -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 (
<div class="mb-8">
<h2 class="text-xl font-medium text-blue-800 mb-4">Create a Reminder</h2>
<Form name="create-reminder" class="bg-white rounded-lg shadow p-6 max-w-lg" method="POST">
<input type="hidden" name="_action" value="create" />
{props.error && (
<div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded">{props.error}</div>
)}
{props.success && (
<div class="mb-4 p-3 bg-green-50 border border-green-200 text-green-800 rounded">
Reminder created successfully!
</div>
)}
<div class="mb-4">
<label class="block text-gray-700 mb-1" for="title">
Title
</label>
<input
id="title"
name="title"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
required
/>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-1" for="dueDate">
Due Date
</label>
<input
id="dueDate"
name="dueDate"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
required
/>
</div>
<div class="mb-6">
<label class="block text-gray-700 mb-1" for="assignee">
Assignee
</label>
<select
id="assignee"
name="assignee"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
>
<option value="">-- Unassigned --</option>
{users.map((user) => (
<option key={user} value={user}>
{user}
</option>
))}
</select>
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
disabled={props.loading}
>
{props.loading ? "Creating..." : "Create Reminder"}
</button>
</Form>
</div>
)
}

View File

@ -1 +0,0 @@
@import "tailwindcss";

View File

@ -0,0 +1,70 @@
import { readdir } from "node:fs/promises"
import { basename, join } from "node:path"
export const startSubdomainServers = async () => {
const portMap: Record<string, number> = {}
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<string, string> } = {}) => {
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
}

View File

@ -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<typeof loader>) => {
return (
<div class="bg-black min-h-screen pb-8">
<h1 class="p-4 text-2xl bg-black text-white">Evals</h1>
<main class="p-4">
<pre class="text-gray-400">{JSON.stringify(props.evaluations, null, 2)}</pre>
</main>
</div>
)
}

View File

@ -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<typeof loader>) {
const host = new URL(import.meta.url).host
return (
<div>
<h1>Subdomain Servers</h1>
<ul>
{packagePaths.map((pkg) => (
<li key={pkg.packageName}>
<a href={`http://${pkg.dirName}.${host}`}>{pkg.packageName}</a>
</li>
))}
</ul>
</div>
)
}

View File

@ -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<typeof loader>) => {
const { data, loading, error } = useAction()
return (
<div class="bg-blue-50 min-h-screen pb-8">
<h1 class="p-4 text-2xl bg-blue-100 text-blue-800">Reminders</h1>
<main class="p-4">
<div>
<h2 class="text-xl font-medium text-blue-800 mb-4">Current Reminders</h2>
<Reminders reminders={props.reminders} />
</div>
<div class="mt-8">
<CreateReminder loading={loading} success={data?.success} error={error ?? data?.error} />
</div>
</main>
</div>
)
}
const Reminders = ({ reminders }: { reminders: Reminder[] }) => {
return (
<>
{reminders.length === 0 ? (
<p class="text-gray-600">No reminders found.</p>
) : (
<ul class="bg-white rounded-lg shadow overflow-hidden divide-y divide-gray-200">
{reminders.map((reminder) => (
<li key={reminder.id} class="p-4 hover:bg-gray-50">
<div class="flex justify-between items-start">
<div>
<div class="font-medium">{reminder.title}</div>
<div class="text-sm text-gray-500 flex justify-between mt-1 items-center gap-2">
<span>Due: {new Date(reminder.dueDate).toLocaleString()}</span>
{reminder.assignee && (
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded ml-2">
{reminder.assignee}
</span>
)}
<div class="text-sm text-gray-500 flex p-2">{reminder.status}</div>
</div>
</div>
<Form method="POST" class="inline-block">
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="id" value={reminder.id} />
<button
type="submit"
class="text-red-600 hover:text-red-800 text-sm font-medium"
title="Delete Reminder"
>
Delete
</button>
</Form>
</div>
</li>
))}
</ul>
)}
</>
)
}

View File

@ -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("")

View File

@ -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

View File

@ -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",

View File

@ -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({

View File

@ -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;
}

View File

@ -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;
}
}
@import "tailwindcss";

View File

@ -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",

View File

@ -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}`)

View File

@ -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
}

View File

@ -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<Decoration>()
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")

View File

@ -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<void>()
// Effect to add temporary completion animation
export const completionAnimationEffect = StateEffect.define<{ pos: number; duration?: number }>()
export const todoDecorations = (filterRef: RefObject<string>) => {
return ViewPlugin.fromClass(
class {
decorations: any
completingLines: Set<number> = 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<string>) => {
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<string>) => {
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<string>) => {
}
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
}

View File

@ -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 <tab>
- [x] Complete a todo by pressing <opt+k>
- [ ] 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<string>,
filterElRef: RefObject<HTMLInputElement>,
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<HTMLDivElement>(null)
const editorRef = useRef<EditorView>(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 && <KeyboardShortcuts onClose={() => setShowShortcuts(false)} />}
<input
type="text"
placeholder="Filter by tag"
placeholder={FILTER_PLACEHOLDER}
value={filter}
ref={filterElRef}
onKeyUp={filterInput}
onKeyUp={handleFilterInput}
onFocus={(e) => (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 (
<div class="fixed inset-0 bg-[#00000088] z-50" onClick={onClose}>
<div class="fixed inset-0 flex items-center justify-center">
<div class="bg-white rounded-sm p-3 shadow-xl" onClick={(e) => e.stopPropagation()}>
<ul class="grid grid-cols-1 gap-2 text-xs">
{buildKeyBindings(undefined as any)
.filter((k) => !k.hidden)
.map((binding) => (
<li class="grid grid-cols-2 items-center gap-4">
<span>{binding.label}</span>
<div class="flex gap-1 justify-end">
{binding.key.split("-").map((part) => (
<kbd class="bg-gray-200 px-2 py-1 rounded">{part}</kbd>
))}
</div>
</li>
))}
{shortcuts.map((binding) => (
<li class="grid grid-cols-2 items-center gap-4" key={binding.key}>
<span>{binding.label}</span>
<div class="flex gap-1 justify-end">
{binding.key.split("-").map((part, index) => (
<kbd class="bg-gray-200 px-2 py-1 rounded" key={index}>
{part}
</kbd>
))}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
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 <tab>
- [x] Complete a todo by pressing <opt+k>
- [ ] 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()

View File

@ -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)
}

View File

@ -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,

View File

@ -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}`)