ok
This commit is contained in:
parent
79e318f89d
commit
968422e021
27
main.ts
27
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
70
packages/http/src/orchestrator.ts
Normal file
70
packages/http/src/orchestrator.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
23
packages/http/src/routes/index.tsx
Normal file
23
packages/http/src/routes/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
144
packages/todo/src/editor.css
Normal file
144
packages/todo/src/editor.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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",
|
||||
20
packages/todo/src/server.tsx
Normal file
20
packages/todo/src/server.tsx
Normal 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}`)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user