wip
This commit is contained in:
parent
ba1f43fd8e
commit
0f9fbc9375
15 changed files with 381 additions and 297 deletions
|
@ -38,22 +38,12 @@ FROM alpine:latest
|
||||||
# Install runtime dependencies for SQLite
|
# Install runtime dependencies for SQLite
|
||||||
RUN apk add --no-cache ca-certificates 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 the binary from builder stage
|
||||||
COPY --from=builder /app/gocheck /app/gocheck
|
COPY --from=builder /app/gocheck /app/gocheck
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
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 default port (can be overridden via PORT env var)
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
22
README.md
22
README.md
|
@ -113,6 +113,8 @@ deployment:
|
||||||
|
|
||||||
#### Using Podman (Recommended)
|
#### Using Podman (Recommended)
|
||||||
|
|
||||||
|
The application is optimized for rootless Podman operation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run with the provided script
|
# Build and run with the provided script
|
||||||
./build-container.sh
|
./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
|
PORT=3000 ./build-container.sh
|
||||||
# Or manually:
|
# Or manually:
|
||||||
podman run -d --name gocheck-container -p 3000:8080 -e PORT=8080 -v gocheck-data:/app gocheck
|
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
|
#### Using Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -142,10 +157,11 @@ docker run -d --name gocheck-container -p 3000:8080 -e PORT=8080 -v gocheck-data
|
||||||
|
|
||||||
#### Container Features
|
#### Container Features
|
||||||
|
|
||||||
- **Multi-stage build**: Uses Go Alpine for building, distroless for runtime
|
- **Multi-stage build**: Uses Go Alpine for building, Alpine for runtime
|
||||||
- **Minimal footprint**: Runtime container is only ~2MB
|
- **Minimal footprint**: Runtime container is optimized for size
|
||||||
- **Persistent data**: SQLite database is stored in a named volume
|
- **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
|
- **Configurable**: Supports environment variables for customization
|
||||||
|
|
||||||
#### Environment Variables
|
#### Environment Variables
|
||||||
|
|
18
build-and-push.sh
Executable file
18
build-and-push.sh
Executable 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
|
|
@ -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)"
|
|
|
@ -5,6 +5,9 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Containerfile
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
USER_ID: 1000
|
||||||
|
GROUP_ID: 1000
|
||||||
container_name: gocheck-container
|
container_name: gocheck-container
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-8080}:8080"
|
- "${PORT:-8080}:8080"
|
||||||
|
@ -13,6 +16,9 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- gocheck-data:/app
|
- gocheck-data:/app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Rootless Podman compatibility
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/" ]
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/" ]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
@ -124,27 +124,33 @@ export default function ChecklistItem({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${depth > 0 ? 'ml-4 border-l-2 border-gray-200 dark:border-gray-700 pl-3' : ''}`}>
|
<li className="group">
|
||||||
<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-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">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
{/* Checkbox */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={handleToggleCheck}
|
onChange={handleToggleCheck}
|
||||||
disabled={Boolean(isLocked && !isLockedByMe)}
|
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
|
<span
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
contentEditable={isEditing}
|
contentEditable={isEditing}
|
||||||
suppressContentEditableWarning={true}
|
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
|
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'
|
? '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 transition-colors duration-200'
|
: '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}
|
onClick={!isEditing ? handleEdit : undefined}
|
||||||
onKeyDown={isEditing ? handleKeyDown : undefined}
|
onKeyDown={isEditing ? handleKeyDown : undefined}
|
||||||
|
@ -154,30 +160,35 @@ export default function ChecklistItem({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 && (
|
{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">
|
<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">
|
||||||
🔒 {item.locked_by}
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<>
|
<button
|
||||||
<button
|
onClick={handleDelete}
|
||||||
onClick={handleDelete}
|
disabled={isDeleting}
|
||||||
disabled={isDeleting}
|
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"
|
||||||
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"
|
title="Delete item"
|
||||||
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" />
|
||||||
</button>
|
</svg>
|
||||||
</>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
{children.length > 0 && (
|
{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 => (
|
{children.map(child => (
|
||||||
<ChecklistItem
|
<ChecklistItem
|
||||||
key={child.id}
|
key={child.id}
|
||||||
|
@ -190,6 +201,6 @@ export default function ChecklistItem({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
76
frontend/src/components/CreateChecklist.tsx
Normal file
76
frontend/src/components/CreateChecklist.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
82
frontend/src/components/SavedChecklists.tsx
Normal file
82
frontend/src/components/SavedChecklists.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,69 +1,44 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import type { SavedChecklist } from '../types'
|
import type { SavedChecklist } from '../types'
|
||||||
|
|
||||||
const STORAGE_KEY = 'gocheck-saved-checklists'
|
const STORAGE_KEY = 'gocheck-saved-checklists'
|
||||||
|
|
||||||
export function useLocalStorage() {
|
export function saveChecklist(checklist: SavedChecklist) {
|
||||||
const [savedChecklists, setSavedChecklists] = useState<SavedChecklist[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSavedChecklists()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadSavedChecklists = () => {
|
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const existingChecklists = loadSavedChecklists()
|
||||||
if (saved) {
|
const existingIndex = existingChecklists.findIndex(c => c.uuid === checklist.uuid);
|
||||||
setSavedChecklists(JSON.parse(saved))
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading saved checklists:', error)
|
console.error('Error saving checklist:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveChecklist = (checklist: SavedChecklist) => {
|
export const loadSavedChecklists = (): SavedChecklist[] => {
|
||||||
try {
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
const updated = [checklist, ...savedChecklists.filter(c => c.uuid !== checklist.uuid)]
|
if (saved) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
try {
|
||||||
setSavedChecklists(updated)
|
const parsed = JSON.parse(saved);
|
||||||
} catch (error) {
|
if (Array.isArray(parsed)) {
|
||||||
console.error('Error saving checklist:', error)
|
return parsed as SavedChecklist[];
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing saved checklists:', 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { SSEEvent, ChecklistItem } from '../types'
|
import type { SSEEvent, ChecklistItem } from '../types'
|
||||||
|
import { saveChecklist } from '../hooks/useLocalStorage'
|
||||||
|
|
||||||
export function useSSE(uuid: string) {
|
export function useSSE(uuid: string) {
|
||||||
const [items, setItems] = useState<ChecklistItem[]>([])
|
const [items, setItems] = useState<ChecklistItem[]>([])
|
||||||
|
const [checkListName, setCheckListName] = useState('')
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const eventSourceRef = useRef<EventSource | null>(null)
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
@ -25,6 +27,16 @@ export function useSSE(uuid: string) {
|
||||||
const data: SSEEvent = JSON.parse(event.data)
|
const data: SSEEvent = JSON.parse(event.data)
|
||||||
|
|
||||||
switch (data.type) {
|
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':
|
case 'full_state':
|
||||||
if (data.items) {
|
if (data.items) {
|
||||||
setItems(data.items)
|
setItems(data.items)
|
||||||
|
@ -109,5 +121,5 @@ export function useSSE(uuid: string) {
|
||||||
}
|
}
|
||||||
}, [uuid])
|
}, [uuid])
|
||||||
|
|
||||||
return { items, isConnected, error }
|
return { items, checkListName, isConnected, error }
|
||||||
}
|
}
|
|
@ -1,30 +1,15 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useSSE } from '../hooks/useSSE'
|
import { useSSE } from '../hooks/useSSE'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
|
||||||
import ChecklistItem from '../components/ChecklistItem'
|
import ChecklistItem from '../components/ChecklistItem'
|
||||||
import type { ChecklistItem as ChecklistItemType } from '../types'
|
import type { ChecklistItem as ChecklistItemType } from '../types'
|
||||||
|
|
||||||
export default function Checklist() {
|
export default function Checklist() {
|
||||||
const { uuid } = useParams<{ uuid: string }>()
|
const { uuid } = useParams<{ uuid: string }>()
|
||||||
const navigate = useNavigate()
|
const { items, checkListName, isConnected, error } = useSSE(uuid || '')
|
||||||
const { items, isConnected, error } = useSSE(uuid || '')
|
|
||||||
const { saveChecklist, getChecklistName } = useLocalStorage()
|
|
||||||
const [newItemContent, setNewItemContent] = useState('')
|
const [newItemContent, setNewItemContent] = useState('')
|
||||||
const [isAddingItem, setIsAddingItem] = useState(false)
|
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 buildItemTree = (items: ChecklistItemType[]): ChecklistItemType[] => {
|
||||||
const itemMap = new Map<number, 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)
|
const itemTree = buildItemTree(items)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
<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="max-w-4xl mx-auto p-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to="/">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<span className="text-xl">←</span>
|
<span className="text-base">←</span>
|
||||||
<span className="font-medium">Back</span>
|
<span className="text-base">Back</span>
|
||||||
</button>
|
</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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isConnected ? (
|
{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>
|
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
Connected
|
Connected
|
||||||
</span>
|
</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>
|
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
Disconnected
|
Disconnected
|
||||||
</span>
|
</span>
|
||||||
|
@ -207,8 +192,8 @@ export default function Checklist() {
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-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-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-red-600 dark:text-red-400">⚠️</span>
|
<span className="text-red-600 dark:text-red-400">⚠️</span>
|
||||||
<span className="text-red-800 dark:text-red-200 font-medium">{error}</span>
|
<span className="text-red-800 dark:text-red-200 font-medium">{error}</span>
|
||||||
|
@ -218,23 +203,23 @@ export default function Checklist() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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 */}
|
{/* Add Item Section */}
|
||||||
<div className=" shadow-sm p-1 mb-1">
|
<div className=" shadow-sm p-1 mb-1">
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Add a new item..."
|
placeholder="Add a new item..."
|
||||||
value={newItemContent}
|
value={newItemContent}
|
||||||
onChange={(e) => setNewItemContent(e.target.value)}
|
onChange={(e) => setNewItemContent(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyDown={handleKeyPress}
|
||||||
disabled={isAddingItem || !isConnected}
|
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
|
<button
|
||||||
onClick={handleAddItem}
|
onClick={handleAddItem}
|
||||||
disabled={isAddingItem || !newItemContent.trim() || !isConnected}
|
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'}
|
{isAddingItem ? 'Adding...' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
|
@ -242,17 +227,17 @@ export default function Checklist() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items Section */}
|
{/* Items Section */}
|
||||||
<div className=" p-2">
|
<div className="p-1">
|
||||||
{itemTree.length === 0 ? (
|
{itemTree.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-10">
|
||||||
<div className="text-gray-400 dark:text-gray-500 text-6xl mb-4">📝</div>
|
<div className="text-gray-400 dark:text-gray-500 text-5xl mb-3">📝</div>
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg font-medium">No items yet</p>
|
<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-2">Add your first item above to get started!</p>
|
<p className="text-gray-500 dark:text-gray-400 mt-1">Add your first item above to get started!</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<ul>
|
||||||
{renderItems(itemTree)}
|
{renderItems(itemTree)}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,129 +1,16 @@
|
||||||
import { useState } from 'react'
|
import CreateChecklist from '../components/CreateChecklist'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import SavedChecklists from '../components/SavedChecklists'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
|
||||||
import type { SavedChecklist } from '../types'
|
|
||||||
|
|
||||||
function Home() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
|
||||||
{/* Create New Checklist Section */}
|
{/* 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">
|
<CreateChecklist className="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>
|
|
||||||
|
|
||||||
{/* Saved Checklists Section */}
|
{/* Saved Checklists Section */}
|
||||||
{savedChecklists.length > 0 && (
|
<SavedChecklists />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,10 +13,12 @@ export interface SavedChecklist {
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
lastOpened?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSEEvent {
|
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[]
|
items?: ChecklistItem[]
|
||||||
item?: ChecklistItem
|
item?: ChecklistItem
|
||||||
id?: number
|
id?: number
|
||||||
|
|
28
main.go
28
main.go
|
@ -84,6 +84,25 @@ func setupDatabase() error {
|
||||||
return nil
|
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) {
|
func loadChecklistItems(uuid string) ([]ChecklistItem, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT id, content, checked, parent_id FROM items WHERE checklist_uuid = ?`,
|
`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
|
// Send full state on connect
|
||||||
items, err := loadChecklistItems(uuid)
|
items, err := loadChecklistItems(uuid)
|
||||||
if err == nil {
|
name, err2 := loadChecklistName(uuid)
|
||||||
|
if err == nil && err2 == nil {
|
||||||
msg, _ := json.Marshal(map[string]interface{}{
|
msg, _ := json.Marshal(map[string]interface{}{
|
||||||
"type": "full_state",
|
"type": "full_state",
|
||||||
"items": items,
|
"items": items,
|
||||||
})
|
})
|
||||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||||
flusher.Flush()
|
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
|
// Forward events
|
||||||
|
|
38
podman-compose.yml
Normal file
38
podman-compose.yml
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue