364 lines
9.1 KiB
TypeScript
364 lines
9.1 KiB
TypeScript
import { Hype } from '@because/hype'
|
|
import { define, stylesToCSS } from '@because/forge'
|
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const APPS_DIR = process.env.APPS_DIR!
|
|
|
|
const app = new Hype({ prettyHTML: false })
|
|
|
|
const Container = define('TodoContainer', {
|
|
fontFamily: theme('fonts-sans'),
|
|
padding: '20px',
|
|
maxWidth: '800px',
|
|
margin: '0 auto',
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const Header = define('Header', {
|
|
marginBottom: '20px',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
})
|
|
|
|
const Title = define('Title', {
|
|
margin: 0,
|
|
fontSize: '24px',
|
|
fontWeight: 'bold',
|
|
})
|
|
|
|
const AppName = define('AppName', {
|
|
color: theme('colors-textMuted'),
|
|
fontSize: '14px',
|
|
})
|
|
|
|
const TodoList = define('TodoList', {
|
|
listStyle: 'none',
|
|
padding: 0,
|
|
margin: 0,
|
|
})
|
|
|
|
const TodoSection = define('TodoSection', {
|
|
marginBottom: '24px',
|
|
})
|
|
|
|
const SectionTitle = define('SectionTitle', {
|
|
fontSize: '16px',
|
|
fontWeight: 600,
|
|
color: theme('colors-textMuted'),
|
|
marginBottom: '12px',
|
|
paddingBottom: '8px',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const TodoItemStyle = define('TodoItem', {
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
padding: '8px 0',
|
|
gap: '10px',
|
|
selectors: {
|
|
'& input[type="checkbox"]': {
|
|
marginTop: '3px',
|
|
width: '18px',
|
|
height: '18px',
|
|
cursor: 'pointer',
|
|
},
|
|
'& label': {
|
|
flex: 1,
|
|
cursor: 'pointer',
|
|
lineHeight: '1.5',
|
|
},
|
|
},
|
|
})
|
|
|
|
const doneClass = 'todo-done'
|
|
|
|
const Error = define('Error', {
|
|
color: theme('colors-error'),
|
|
padding: '20px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
borderRadius: theme('radius-md'),
|
|
margin: '20px 0',
|
|
})
|
|
|
|
const successClass = 'msg-success'
|
|
const errorClass = 'msg-error'
|
|
|
|
const SaveButton = define('SaveButton', {
|
|
base: 'button',
|
|
backgroundColor: theme('colors-primary'),
|
|
color: theme('colors-primaryText'),
|
|
border: 'none',
|
|
padding: '8px 16px',
|
|
borderRadius: theme('radius-md'),
|
|
cursor: 'pointer',
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
states: {
|
|
':hover': {
|
|
opacity: 0.9,
|
|
},
|
|
':disabled': {
|
|
opacity: 0.5,
|
|
cursor: 'not-allowed',
|
|
},
|
|
},
|
|
})
|
|
|
|
const AddForm = define('AddForm', {
|
|
display: 'flex',
|
|
gap: '10px',
|
|
marginBottom: '20px',
|
|
})
|
|
|
|
const AddInput = define('AddInput', {
|
|
base: 'input',
|
|
flex: 1,
|
|
padding: '8px 12px',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
fontSize: '14px',
|
|
backgroundColor: theme('colors-bg'),
|
|
color: theme('colors-text'),
|
|
states: {
|
|
':focus': {
|
|
outline: 'none',
|
|
borderColor: theme('colors-primary'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const todoStyles = `
|
|
.${doneClass} {
|
|
color: ${theme('colors-done')};
|
|
text-decoration: line-through;
|
|
}
|
|
.${successClass} {
|
|
padding: 12px 16px;
|
|
border-radius: ${theme('radius-md')};
|
|
margin-bottom: 16px;
|
|
background-color: ${theme('colors-successBg')};
|
|
color: ${theme('colors-success')};
|
|
}
|
|
.${errorClass} {
|
|
padding: 12px 16px;
|
|
border-radius: ${theme('radius-md')};
|
|
margin-bottom: 16px;
|
|
background-color: ${theme('colors-bgElement')};
|
|
color: ${theme('colors-error')};
|
|
}
|
|
`
|
|
|
|
interface TodoEntry {
|
|
text: string
|
|
done: boolean
|
|
}
|
|
|
|
interface ParsedTodo {
|
|
title: string
|
|
items: TodoEntry[]
|
|
}
|
|
|
|
function parseTodoFile(content: string): ParsedTodo {
|
|
const lines = content.split('\n')
|
|
let title = 'TODO'
|
|
const items: TodoEntry[] = []
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
|
|
if (trimmed.startsWith('# ')) {
|
|
title = trimmed.slice(2)
|
|
} else if (trimmed.startsWith('[x] ') || trimmed.startsWith('[X] ')) {
|
|
items.push({ text: trimmed.slice(4), done: true })
|
|
} else if (trimmed.startsWith('[ ] ')) {
|
|
items.push({ text: trimmed.slice(4), done: false })
|
|
}
|
|
}
|
|
|
|
return { title, items }
|
|
}
|
|
|
|
function serializeTodo(todo: ParsedTodo): string {
|
|
const lines = [`# ${todo.title}`]
|
|
for (const item of todo.items) {
|
|
lines.push(item.done ? `[x] ${item.text}` : `[ ] ${item.text}`)
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
|
|
'Content-Type': 'text/css; charset=utf-8',
|
|
}))
|
|
|
|
app.get('/', async c => {
|
|
const appName = c.req.query('app')
|
|
const message = c.req.query('message')
|
|
const messageType = c.req.query('type') as 'success' | 'error' | undefined
|
|
|
|
if (!appName) {
|
|
return c.html(
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>TODO</title>
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
</head>
|
|
<body>
|
|
<ToolScript />
|
|
<Container>
|
|
<Header>
|
|
<Title>TODO</Title>
|
|
</Header>
|
|
<Error>Select an app to view its TODO list</Error>
|
|
</Container>
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
|
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
|
|
|
let todo: ParsedTodo
|
|
if (existsSync(todoPath)) {
|
|
const content = readFileSync(todoPath, 'utf-8')
|
|
todo = parseTodoFile(content)
|
|
} else {
|
|
todo = { title: `${appName} TODO`, items: [] }
|
|
}
|
|
|
|
const pendingItems = todo.items.filter(i => !i.done)
|
|
|
|
return c.html(
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>{todo.title}</title>
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
</head>
|
|
<body>
|
|
<ToolScript />
|
|
<Container>
|
|
<Header>
|
|
<div>
|
|
<Title>{todo.title}</Title>
|
|
<AppName>{appName}/TODO.txt</AppName>
|
|
</div>
|
|
</Header>
|
|
|
|
{message && (
|
|
<div class={messageType === 'success' ? successClass : errorClass}>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
<form action="/add" method="post">
|
|
<input type="hidden" name="app" value={appName} />
|
|
<AddForm>
|
|
<AddInput type="text" name="text" placeholder="Add a new todo..." required />
|
|
<SaveButton type="submit">Add</SaveButton>
|
|
</AddForm>
|
|
</form>
|
|
|
|
{todo.items.length > 0 && (
|
|
<TodoSection>
|
|
<SectionTitle>
|
|
{pendingItems.length === 0 ? 'All done!' : `Pending (${pendingItems.length})`}
|
|
</SectionTitle>
|
|
<TodoList>
|
|
{todo.items.map((item, i) => (
|
|
<TodoItemStyle key={i}>
|
|
<form action="/toggle" method="post" style={{ display: 'contents' }}>
|
|
<input type="hidden" name="app" value={appName} />
|
|
<input type="hidden" name="index" value={i.toString()} />
|
|
<input
|
|
type="checkbox"
|
|
id={`item-${i}`}
|
|
checked={item.done}
|
|
onchange="this.form.submit()"
|
|
/>
|
|
<label for={`item-${i}`} class={item.done ? doneClass : ''}>
|
|
{item.text}
|
|
</label>
|
|
</form>
|
|
</TodoItemStyle>
|
|
))}
|
|
</TodoList>
|
|
</TodoSection>
|
|
)}
|
|
|
|
{todo.items.length === 0 && (
|
|
<TodoSection>
|
|
<SectionTitle>No todos yet</SectionTitle>
|
|
<p style={{ color: theme('colors-textMuted') }}>Add your first todo above!</p>
|
|
</TodoSection>
|
|
)}
|
|
</Container>
|
|
</body>
|
|
</html>
|
|
)
|
|
})
|
|
|
|
app.post('/toggle', async c => {
|
|
const form = await c.req.formData()
|
|
const appName = form.get('app') as string
|
|
const index = parseInt(form.get('index') as string, 10)
|
|
|
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
|
|
|
let todo: ParsedTodo
|
|
if (existsSync(todoPath)) {
|
|
const content = readFileSync(todoPath, 'utf-8')
|
|
todo = parseTodoFile(content)
|
|
} else {
|
|
return c.redirect(`/?app=${appName}`)
|
|
}
|
|
|
|
if (index >= 0 && index < todo.items.length) {
|
|
todo.items[index].done = !todo.items[index].done
|
|
}
|
|
|
|
try {
|
|
writeFileSync(todoPath, serializeTodo(todo))
|
|
return c.redirect(`/?app=${appName}`)
|
|
} catch {
|
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to save')}&type=error`)
|
|
}
|
|
})
|
|
|
|
app.post('/add', async c => {
|
|
const form = await c.req.formData()
|
|
const appName = form.get('app') as string
|
|
const text = (form.get('text') as string).trim()
|
|
|
|
if (!text) {
|
|
return c.redirect(`/?app=${appName}`)
|
|
}
|
|
|
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
|
|
|
let todo: ParsedTodo
|
|
if (existsSync(todoPath)) {
|
|
const content = readFileSync(todoPath, 'utf-8')
|
|
todo = parseTodoFile(content)
|
|
} else {
|
|
todo = { title: `${appName} TODO`, items: [] }
|
|
}
|
|
|
|
todo.items.push({ text, done: false })
|
|
|
|
try {
|
|
writeFileSync(todoPath, serializeTodo(todo))
|
|
return c.redirect(`/?app=${appName}`)
|
|
} catch {
|
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to add')}&type=error`)
|
|
}
|
|
})
|
|
|
|
export default app.defaults
|