This commit is contained in:
lubiana 2025-07-24 11:35:37 +02:00
parent ba1f43fd8e
commit 0f9fbc9375
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
15 changed files with 381 additions and 297 deletions

View file

@ -38,22 +38,12 @@ FROM alpine:latest
# Install runtime dependencies for SQLite
RUN apk add --no-cache ca-certificates sqlite
# Create non-root user
RUN addgroup -g 1001 -S gocheck && \
adduser -S -D -H -h /app -s /sbin/nologin -G gocheck -g gocheck -u 1001 gocheck
# Copy the binary from builder stage
COPY --from=builder /app/gocheck /app/gocheck
# Set working directory
WORKDIR /app
# Change ownership to non-root user
RUN chown -R gocheck:gocheck /app
# Switch to non-root user
USER gocheck
# Expose default port (can be overridden via PORT env var)
EXPOSE 8080

View file

@ -113,6 +113,8 @@ deployment:
#### Using Podman (Recommended)
The application is optimized for rootless Podman operation:
```bash
# Build and run with the provided script
./build-container.sh
@ -125,8 +127,21 @@ podman run -d --name gocheck-container -p 8080:8080 -v gocheck-data:/app gocheck
PORT=3000 ./build-container.sh
# Or manually:
podman run -d --name gocheck-container -p 3000:8080 -e PORT=8080 -v gocheck-data:/app gocheck
# Using Podman Compose (recommended for rootless):
podman-compose -f podman-compose.yml up -d
# Or with Docker Compose (also works with Podman):
docker-compose up -d
```
**Rootless Podman Compatibility:**
- Uses UID/GID 1000 for better user mapping
- Includes security options for rootless operation
- Supports both `podman-compose` and `docker-compose`
- Alternative compose file available: `podman-compose.yml`
#### Using Docker
```bash
@ -142,10 +157,11 @@ docker run -d --name gocheck-container -p 3000:8080 -e PORT=8080 -v gocheck-data
#### Container Features
- **Multi-stage build**: Uses Go Alpine for building, distroless for runtime
- **Minimal footprint**: Runtime container is only ~2MB
- **Multi-stage build**: Uses Go Alpine for building, Alpine for runtime
- **Minimal footprint**: Runtime container is optimized for size
- **Persistent data**: SQLite database is stored in a named volume
- **Security**: Runs as non-root user in distroless container
- **Security**: Runs as non-root user with UID 1000
- **Rootless Podman compatible**: Optimized for rootless container operation
- **Configurable**: Supports environment variables for customization
#### Environment Variables

18
build-and-push.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env sh
MANIFEST=git.hannover.ccc.de/lubiana/cheekylist
AMD=${MANIFEST}amd64
ARM=${MANIFEST}arm64
podman manifest exists $MANIFEST
if [ $? -eq 0 ]; then
podman manifest rm $MANIFEST
fi
podman manifest create $MANIFEST
podman build --arch=amd64 -t $AMD .
podman build --arch=arm64 -t $ARM .
podman manifest add $MANIFEST $AMD $ARM
podman manifest push $MANIFEST

View file

@ -1,40 +0,0 @@
#!/bin/bash
# Build script for GoCheck container
set -e
IMAGE_NAME="gocheck"
CONTAINER_NAME="gocheck-container"
PORT="${PORT:-8080}"
echo "<22><> Building GoCheck frontend..."
echo "🏗️ Building GoCheck container..."
# Build the container image
podman build -t $IMAGE_NAME -f Containerfile .
echo "✅ Container built successfully!"
echo "🚀 Starting GoCheck container on port $PORT..."
podman run -d \
--rm \
--name $CONTAINER_NAME \
-p $PORT:8080 \
-e PORT=8080 \
-v gocheck-data:/app \
$IMAGE_NAME
echo "✅ GoCheck is running!"
echo "🌐 Access the application at: http://localhost:$PORT"
echo ""
echo "📋 Container management commands:"
echo " View logs: podman logs $CONTAINER_NAME"
echo " Stop: podman stop $CONTAINER_NAME"
echo " Start: podman start $CONTAINER_NAME"
echo " Remove: podman rm $CONTAINER_NAME"
echo " Shell access: podman exec -it $CONTAINER_NAME /bin/sh"
echo ""
echo "🔧 Environment variables:"
echo " PORT: Set to override the default port (default: 8080)"

View file

@ -5,6 +5,9 @@ services:
build:
context: .
dockerfile: Containerfile
args:
USER_ID: 1000
GROUP_ID: 1000
container_name: gocheck-container
ports:
- "${PORT:-8080}:8080"
@ -13,6 +16,9 @@ services:
volumes:
- gocheck-data:/app
restart: unless-stopped
# Rootless Podman compatibility
security_opt:
- no-new-privileges:true
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/" ]
interval: 30s

View file

@ -124,27 +124,33 @@ export default function ChecklistItem({
}
return (
<div className={`${depth > 0 ? 'ml-4 border-l-2 border-gray-200 dark:border-gray-700 pl-3' : ''}`}>
<div className="flex items-center justify-between py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg px-2 transition-colors duration-200">
<div className="flex items-center gap-3 flex-1 min-w-0">
<li className="group">
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all duration-200 border border-transparent hover:border-gray-200 dark:hover:border-gray-700">
{/* Checkbox */}
<div className="flex-shrink-0 mt-0.5">
<input
type="checkbox"
checked={item.checked}
onChange={handleToggleCheck}
disabled={Boolean(isLocked && !isLockedByMe)}
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-5 h-5 text-blue-600 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500"
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<span
ref={contentRef}
contentEditable={isEditing}
suppressContentEditableWarning={true}
className={`flex-1 text-sm text-gray-700 dark:text-gray-300 break-words outline-none ${
className={`block w-full text-base leading-relaxed break-words outline-none transition-all duration-200 ${
isEditing
? 'px-2 py-1 border-2 border-blue-500 rounded focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 bg-white dark:bg-gray-800'
: 'cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200'
? 'px-3 py-2 border-2 border-blue-500 rounded-lg focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 bg-white dark:bg-gray-800 shadow-sm'
: 'cursor-pointer hover:text-blue-600 dark:hover:text-blue-400'
} ${
item.checked ? 'line-through text-gray-500 dark:text-gray-500' : ''
item.checked
? 'line-through text-gray-500 dark:text-gray-500 opacity-75'
: 'text-gray-900 dark:text-gray-100'
}`}
onClick={!isEditing ? handleEdit : undefined}
onKeyDown={isEditing ? handleKeyDown : undefined}
@ -154,30 +160,35 @@ export default function ChecklistItem({
</span>
</div>
<div className="flex items-center gap-2 ml-2">
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{isLocked && !isLockedByMe && (
<span className="text-xs text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded-full whitespace-nowrap">
🔒 {item.locked_by}
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded-full whitespace-nowrap border border-red-200 dark:border-red-800">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
{item.locked_by}
</span>
)}
{!isEditing && (
<>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed group/delete"
title="Delete item"
>
🗑
<svg className="w-4 h-4 group-hover/delete:scale-110 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
{/* Children */}
{children.length > 0 && (
<div className="mt-1">
<div className="ml-8 mt-2 space-y-2 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
{children.map(child => (
<ChecklistItem
key={child.id}
@ -190,6 +201,6 @@ export default function ChecklistItem({
))}
</div>
)}
</div>
</li>
)
}

View file

@ -0,0 +1,76 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
interface CreateChecklistProps {
className?: string
}
export default function CreateChecklist({ className = '' }: CreateChecklistProps) {
const [checklistName, setChecklistName] = useState('')
const [isCreating, setIsCreating] = useState(false)
const navigate = useNavigate()
const createChecklist = async () => {
if (!checklistName.trim()) return
setIsCreating(true)
try {
const response = await fetch('/api/checklists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: checklistName.trim() }),
})
if (response.ok) {
const data = await response.json()
// Navigate to the new checklist
navigate(`/${data.uuid}`)
} else {
alert('Failed to create checklist')
}
} catch (error) {
console.error('Error creating checklist:', error)
alert('Failed to create checklist')
} finally {
setIsCreating(false)
setChecklistName('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
createChecklist()
}
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 ${className}`}>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Create New Checklist</h2>
<div className="flex flex-col sm:flex-row gap-4">
<input
type="text"
placeholder="Enter checklist name..."
value={checklistName}
onChange={(e) => setChecklistName(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isCreating}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={createChecklist}
disabled={isCreating || !checklistName.trim()}
className="
px-8 py-3 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50
disabled:cursor-not-allowed transition-colors duration-200
"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { Link } from 'react-router-dom'
import { loadSavedChecklists } from '../hooks/useLocalStorage'
import { useState } from 'react'
interface SavedChecklistsProps {
className?: string
}
export default function SavedChecklists({ className = '' }: SavedChecklistsProps) {
const [savedChecklists, setSavedChecklists] = useState<{ uuid: string; name: string; lastOpened?: string }[]>(loadSavedChecklists())
const handleForgetCheckList = (uuid: string): void => {
const updatedChecklists = savedChecklists.filter((checklist: { uuid: string }) => checklist.uuid !== uuid)
localStorage.setItem('gocheck-saved-checklists', JSON.stringify(updatedChecklists))
setSavedChecklists(updatedChecklists)
}
if (savedChecklists.length === 0) {
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center ${className}`}>
<div className="text-gray-400 dark:text-gray-500 text-6xl mb-4">📋</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">No checklists yet</h3>
<p className="text-gray-600 dark:text-gray-300">Create your first checklist above to get started!</p>
</div>
)
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 ${className}`}>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Your Checklists</h2>
<div className="space-y-4">
{savedChecklists.map((checklist) => (
<div key={checklist.uuid} className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200">
<div className="flex-1 mb-4 sm:mb-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{checklist.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Last opened: {checklist.lastOpened ? new Date(checklist.lastOpened).toLocaleString() : 'Never'}
</p>
</div>
<div className="flex items-center gap-3">
<Link to={`/${checklist.uuid}`}>
<button
className="px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200"
>
Open
</button>
</Link>
<button
onClick={() => {
const url = `${window.location.origin}/${checklist.uuid}`
navigator.clipboard.writeText(url).then(() => {
// Optional: Add a toast notification here
alert('Checklist link copied to clipboard! ' + url)
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = url
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('Checklist link copied to clipboard!')
})
}}
className="px-4 py-2 bg-green-600 dark:bg-green-500 text-white font-medium rounded-lg hover:bg-green-700 dark:hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200"
title="Share checklist link"
>
📤 Share
</button>
<button
onClick={() => handleForgetCheckList(checklist.uuid)}
className="px-4 py-2 bg-red-600 dark:bg-red-500 text-white font-medium rounded-lg hover:bg-red-700 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200"
>
Forget
</button>
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -1,69 +1,44 @@
import { useState, useEffect } from 'react'
import type { SavedChecklist } from '../types'
const STORAGE_KEY = 'gocheck-saved-checklists'
export function useLocalStorage() {
const [savedChecklists, setSavedChecklists] = useState<SavedChecklist[]>([])
useEffect(() => {
loadSavedChecklists()
}, [])
const loadSavedChecklists = () => {
export function saveChecklist(checklist: SavedChecklist) {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
setSavedChecklists(JSON.parse(saved))
const existingChecklists = loadSavedChecklists()
const existingIndex = existingChecklists.findIndex(c => c.uuid === checklist.uuid);
if (existingIndex > -1) {
// Update lastOpened timestamp if checklist already exists
existingChecklists[existingIndex].lastOpened = new Date().toISOString();
} else {
// Add new checklist with lastOpened timestamp
existingChecklists.push({ ...checklist, lastOpened: new Date().toISOString() });
}
// Sort by lastOpened date in descending order
existingChecklists.sort((a, b) => {
if (!a.lastOpened) return 1;
if (!b.lastOpened) return -1;
return new Date(b.lastOpened).getTime() - new Date(a.lastOpened).getTime();
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(existingChecklists));
} catch (error) {
console.error('Error loading saved checklists:', error)
}
}
const saveChecklist = (checklist: SavedChecklist) => {
try {
const updated = [checklist, ...savedChecklists.filter(c => c.uuid !== checklist.uuid)]
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
setSavedChecklists(updated)
} catch (error) {
console.error('Error saving checklist:', error)
}
}
const deleteChecklist = (uuid: string) => {
try {
const updated = savedChecklists.filter(c => c.uuid !== uuid)
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
setSavedChecklists(updated)
} catch (error) {
console.error('Error deleting checklist:', error)
}
}
const updateChecklistName = (uuid: string, newName: string) => {
try {
const updated = savedChecklists.map(c =>
c.uuid === uuid ? { ...c, name: newName } : c
)
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
setSavedChecklists(updated)
} catch (error) {
console.error('Error updating checklist name:', error)
}
}
const getChecklistName = (uuid: string): string | null => {
const checklist = savedChecklists.find(c => c.uuid === uuid)
return checklist ? checklist.name : null
}
return {
savedChecklists,
saveChecklist,
deleteChecklist,
updateChecklistName,
getChecklistName,
loadSavedChecklists
console.error('Error saving checklist:', error);
}
}
export const loadSavedChecklists = (): SavedChecklist[] => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
return parsed as SavedChecklist[];
}
} catch (error) {
console.error('Error parsing saved checklists:', error);
}
}
return []
}

View file

@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type { SSEEvent, ChecklistItem } from '../types'
import { saveChecklist } from '../hooks/useLocalStorage'
export function useSSE(uuid: string) {
const [items, setItems] = useState<ChecklistItem[]>([])
const [checkListName, setCheckListName] = useState('')
const [isConnected, setIsConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
@ -25,6 +27,16 @@ export function useSSE(uuid: string) {
const data: SSEEvent = JSON.parse(event.data)
switch (data.type) {
case 'checklist_name':
if (data.name) {
saveChecklist({
uuid,
name: data.name,
createdAt: new Date().toISOString(),
})
setCheckListName(data.name)
}
break
case 'full_state':
if (data.items) {
setItems(data.items)
@ -109,5 +121,5 @@ export function useSSE(uuid: string) {
}
}, [uuid])
return { items, isConnected, error }
return { items, checkListName, isConnected, error }
}

View file

@ -1,30 +1,15 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useSSE } from '../hooks/useSSE'
import { useLocalStorage } from '../hooks/useLocalStorage'
import ChecklistItem from '../components/ChecklistItem'
import type { ChecklistItem as ChecklistItemType } from '../types'
export default function Checklist() {
const { uuid } = useParams<{ uuid: string }>()
const navigate = useNavigate()
const { items, isConnected, error } = useSSE(uuid || '')
const { saveChecklist, getChecklistName } = useLocalStorage()
const { items, checkListName, isConnected, error } = useSSE(uuid || '')
const [newItemContent, setNewItemContent] = useState('')
const [isAddingItem, setIsAddingItem] = useState(false)
// Save checklist to local storage if not already saved
useEffect(() => {
if (uuid && !getChecklistName(uuid)) {
// Generate a default name if not in local storage
const defaultName = `Checklist ${new Date().toLocaleDateString()}`
saveChecklist({
uuid,
name: defaultName,
createdAt: new Date().toISOString()
})
}
}, [uuid, saveChecklist, getChecklistName])
const buildItemTree = (items: ChecklistItemType[]): ChecklistItemType[] => {
const itemMap = new Map<number, ChecklistItemType>()
@ -169,33 +154,33 @@ export default function Checklist() {
)
}
const checklistName = getChecklistName(uuid) || 'Untitled Checklist'
const itemTree = buildItemTree(items)
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="max-w-4xl mx-auto p-1">
<div className="flex flex-col sm:flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Link to="/">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
className="flex items-center gap-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
>
<span className="text-xl"></span>
<span className="font-medium">Back</span>
<span className="text-base"></span>
<span className="text-base">Back</span>
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{checklistName}</h1>
</Link>
<h1 className="text-base font-bold text-gray-900 dark:text-white">{checkListName}</h1>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-3 py-1 rounded-full text-sm font-medium">
<span className="flex items-center gap-2 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-2 py-0.5 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Connected
</span>
) : (
<span className="flex items-center gap-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-3 py-1 rounded-full text-sm font-medium">
<span className="flex items-center gap-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-2 py-0.5 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Disconnected
</span>
@ -207,8 +192,8 @@ export default function Checklist() {
{/* Error Message */}
{error && (
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="max-w-4xl mx-auto px-2 py-2">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-2">
<div className="flex items-center gap-2">
<span className="text-red-600 dark:text-red-400"></span>
<span className="text-red-800 dark:text-red-200 font-medium">{error}</span>
@ -218,23 +203,23 @@ export default function Checklist() {
)}
{/* Main Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="max-w-4xl mx-auto px-2 py-4">
{/* Add Item Section */}
<div className=" shadow-sm p-1 mb-1">
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Add a new item..."
value={newItemContent}
onChange={(e) => setNewItemContent(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyPress}
disabled={isAddingItem || !isConnected}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
className="flex-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={handleAddItem}
disabled={isAddingItem || !newItemContent.trim() || !isConnected}
className="px-6 py-2 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
className="px-4 py-1 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isAddingItem ? 'Adding...' : 'Add'}
</button>
@ -242,17 +227,17 @@ export default function Checklist() {
</div>
{/* Items Section */}
<div className=" p-2">
<div className="p-1">
{itemTree.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 text-6xl mb-4">📝</div>
<p className="text-gray-600 dark:text-gray-300 text-lg font-medium">No items yet</p>
<p className="text-gray-500 dark:text-gray-400 mt-2">Add your first item above to get started!</p>
<div className="text-center py-10">
<div className="text-gray-400 dark:text-gray-500 text-5xl mb-3">📝</div>
<p className="text-gray-600 dark:text-gray-300 text-base font-medium">No items yet</p>
<p className="text-gray-500 dark:text-gray-400 mt-1">Add your first item above to get started!</p>
</div>
) : (
<div className="space-y-1">
<ul>
{renderItems(itemTree)}
</div>
</ul>
)}
</div>
</div>

View file

@ -1,129 +1,16 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocalStorage } from '../hooks/useLocalStorage'
import type { SavedChecklist } from '../types'
import CreateChecklist from '../components/CreateChecklist'
import SavedChecklists from '../components/SavedChecklists'
function Home() {
const [checklistName, setChecklistName] = useState('')
const [isCreating, setIsCreating] = useState(false)
const navigate = useNavigate()
const { savedChecklists, saveChecklist, deleteChecklist } = useLocalStorage()
const createChecklist = async () => {
if (!checklistName.trim()) return
setIsCreating(true)
try {
const response = await fetch('/api/checklists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: checklistName.trim() }),
})
if (response.ok) {
const data = await response.json()
const newChecklist: SavedChecklist = {
uuid: data.uuid,
name: checklistName.trim(),
createdAt: new Date().toISOString(),
}
// Save to local storage
saveChecklist(newChecklist)
// Navigate to the new checklist
navigate(`/${data.uuid}`)
} else {
alert('Failed to create checklist')
}
} catch (error) {
console.error('Error creating checklist:', error)
alert('Failed to create checklist')
} finally {
setIsCreating(false)
setChecklistName('')
}
}
const handleDeleteChecklist = (uuid: string) => {
deleteChecklist(uuid)
}
// Note: handleUpdateChecklistName is available for future use
// const handleUpdateChecklistName = (uuid: string, newName: string) => {
// updateChecklistName(uuid, newName)
// }
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Create New Checklist Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Create New Checklist</h2>
<div className="flex gap-4">
<input
type="text"
placeholder="Enter checklist name..."
value={checklistName}
onChange={(e) => setChecklistName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && createChecklist()}
disabled={isCreating}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={createChecklist}
disabled={isCreating || !checklistName.trim()}
className="px-8 py-3 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
<CreateChecklist className="mb-8" />
{/* Saved Checklists Section */}
{savedChecklists.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Your Checklists</h2>
<div className="space-y-4">
{savedChecklists.map((checklist) => (
<div key={checklist.uuid} className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{checklist.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Created: {new Date(checklist.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => navigate(`/${checklist.uuid}`)}
className="px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200"
>
Open
</button>
<button
onClick={() => handleDeleteChecklist(checklist.uuid)}
className="px-4 py-2 bg-red-600 dark:bg-red-500 text-white font-medium rounded-lg hover:bg-red-700 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{savedChecklists.length === 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="text-gray-400 dark:text-gray-500 text-6xl mb-4">📋</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">No checklists yet</h3>
<p className="text-gray-600 dark:text-gray-300">Create your first checklist above to get started!</p>
</div>
)}
<SavedChecklists />
</div>
</div>
)

View file

@ -13,10 +13,12 @@ export interface SavedChecklist {
uuid: string
name: string
createdAt: string
lastOpened?: string
}
export interface SSEEvent {
type: 'full_state' | 'item_added' | 'item_updated' | 'item_deleted' | 'item_locked' | 'item_unlocked'
type: 'full_state' | 'item_added' | 'item_updated' | 'item_deleted' | 'item_locked' | 'item_unlocked' | 'checklist_name'
name?: string
items?: ChecklistItem[]
item?: ChecklistItem
id?: number

28
main.go
View file

@ -84,6 +84,25 @@ func setupDatabase() error {
return nil
}
func loadChecklistName(uuid string) (string, error) {
rows, err := db.Query(
`SELECT name FROM checklists WHERE uuid = ?`,
uuid)
if err != nil {
return "", err
}
defer rows.Close()
if rows.Next() {
var name string
err = rows.Scan(&name)
if err != nil {
return "", err
}
return name, nil
}
return "", fmt.Errorf("not found")
}
func loadChecklistItems(uuid string) ([]ChecklistItem, error) {
rows, err := db.Query(
`SELECT id, content, checked, parent_id FROM items WHERE checklist_uuid = ?`,
@ -427,13 +446,20 @@ func handleSSE(w http.ResponseWriter, r *http.Request) {
// Send full state on connect
items, err := loadChecklistItems(uuid)
if err == nil {
name, err2 := loadChecklistName(uuid)
if err == nil && err2 == nil {
msg, _ := json.Marshal(map[string]interface{}{
"type": "full_state",
"items": items,
})
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
msg, _ = json.Marshal(map[string]interface{}{
"type": "checklist_name",
"name": name,
})
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
}
// Forward events

38
podman-compose.yml Normal file
View file

@ -0,0 +1,38 @@
services:
gocheck:
build:
context: .
dockerfile: Containerfile
args:
USER_ID: 1000
GROUP_ID: 1000
container_name: gocheck-container
ports:
- "${PORT:-8080}:8080"
environment:
- PORT=8080
volumes:
- gocheck-data:/app
restart: unless-stopped
# Rootless Podman compatibility
security_opt:
- no-new-privileges:true
# Use host networking for better rootless compatibility
network_mode: host
healthcheck:
test: [
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8080/",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
gocheck-data:
driver: local