New standalone page: firefrostgaming.com/tasks - Mobile-optimized task viewer/editor - Connects directly to Gitea API (no CORS issues) - Card-based interface with tap-to-expand - Filter tabs: All / Blockers / Active / Done - Quick edit: Status, Priority, Owner, Blocker checkbox - Auto-saves changes to Git - Touch-friendly (44px+ tap targets) - Priority color-coded cards - Works on phone/tablet/desktop Replaces clunky Decap CMS mobile experience with purpose-built task manager.
494 lines
20 KiB
Plaintext
494 lines
20 KiB
Plaintext
---
|
|
layout: base
|
|
title: Task Manager - Firefrost Gaming
|
|
eleventyExcludeFromCollections: true
|
|
---
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tasks - Firefrost Gaming</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
#root {
|
|
min-height: 100vh;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect } = React;
|
|
|
|
const GITEA_API = 'https://git.firefrostgaming.com/api/v1';
|
|
const REPO_OWNER = 'firefrost-gaming';
|
|
const REPO_NAME = 'firefrost-operations-manual';
|
|
const TOKEN = 'e0e330cba1749b01ab505093a160e4423ebbbe36';
|
|
|
|
// Parse markdown frontmatter and body
|
|
const parseMarkdown = (content) => {
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
if (!frontmatterMatch) return { frontmatter: {}, body: content };
|
|
|
|
const frontmatter = {};
|
|
const yamlLines = frontmatterMatch[1].split('\n');
|
|
|
|
yamlLines.forEach(line => {
|
|
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
if (match) {
|
|
const [, key, value] = match;
|
|
if (value === 'true') frontmatter[key] = true;
|
|
else if (value === 'false') frontmatter[key] = false;
|
|
else if (!isNaN(value) && value.trim() !== '') frontmatter[key] = parseInt(value);
|
|
else frontmatter[key] = value.trim();
|
|
}
|
|
});
|
|
|
|
return { frontmatter, body: frontmatterMatch[2] };
|
|
};
|
|
|
|
// Serialize back to markdown
|
|
const serializeMarkdown = (frontmatter, body) => {
|
|
const yamlLines = Object.entries(frontmatter).map(([key, value]) => {
|
|
return `${key}: ${value}`;
|
|
});
|
|
|
|
return `---\n${yamlLines.join('\n')}\n---\n${body}`;
|
|
};
|
|
|
|
const MobileTaskManager = () => {
|
|
const [tasks, setTasks] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [expandedTask, setExpandedTask] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [filter, setFilter] = useState('all');
|
|
|
|
const loadTasks = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(
|
|
`${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/tasks?ref=master`,
|
|
{
|
|
headers: {
|
|
'Authorization': `token ${TOKEN}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Failed to load tasks');
|
|
|
|
const files = await response.json();
|
|
const taskFiles = files.filter(f => f.name.startsWith('task-') && f.name.endsWith('.md'));
|
|
|
|
const taskPromises = taskFiles.map(async (file) => {
|
|
const contentResponse = await fetch(
|
|
`${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${file.path}?ref=master`,
|
|
{
|
|
headers: {
|
|
'Authorization': `token ${TOKEN}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const fileData = await contentResponse.json();
|
|
const content = atob(fileData.content);
|
|
const { frontmatter, body } = parseMarkdown(content);
|
|
|
|
return {
|
|
path: file.path,
|
|
sha: fileData.sha,
|
|
name: file.name,
|
|
frontmatter,
|
|
body,
|
|
number: frontmatter.number || 0
|
|
};
|
|
});
|
|
|
|
const loadedTasks = await Promise.all(taskPromises);
|
|
|
|
const priorityOrder = { 'P0-Blocker': 0, 'P1-High': 1, 'P2-Medium': 2, 'P3-Low': 3, 'P4-Personal': 4 };
|
|
loadedTasks.sort((a, b) => {
|
|
const priorityA = priorityOrder[a.frontmatter.priority] ?? 5;
|
|
const priorityB = priorityOrder[b.frontmatter.priority] ?? 5;
|
|
if (priorityA !== priorityB) return priorityA - priorityB;
|
|
return a.number - b.number;
|
|
});
|
|
|
|
setTasks(loadedTasks);
|
|
setLoading(false);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadTasks();
|
|
}, []);
|
|
|
|
const saveTask = async (task) => {
|
|
try {
|
|
setSaving(true);
|
|
const content = serializeMarkdown(task.frontmatter, task.body);
|
|
const base64Content = btoa(content);
|
|
|
|
const response = await fetch(
|
|
`${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${task.path}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `token ${TOKEN}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
content: base64Content,
|
|
sha: task.sha,
|
|
message: `Update Task #${task.number} via mobile task manager`,
|
|
branch: 'master'
|
|
})
|
|
}
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Failed to save task');
|
|
|
|
await loadTasks();
|
|
setExpandedTask(null);
|
|
setSaving(false);
|
|
} catch (err) {
|
|
alert('Error saving task: ' + err.message);
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const updateTaskField = (task, field, value) => {
|
|
const updatedTask = {
|
|
...task,
|
|
frontmatter: {
|
|
...task.frontmatter,
|
|
[field]: value
|
|
}
|
|
};
|
|
saveTask(updatedTask);
|
|
};
|
|
|
|
const getPriorityColor = (priority) => {
|
|
switch (priority) {
|
|
case 'P0-Blocker': return '#dc3545';
|
|
case 'P1-High': return '#FF6B35';
|
|
case 'P1': return '#FF6B35';
|
|
case 'P2-Medium': return '#ffc107';
|
|
case 'P3-Low': return '#4ECDC4';
|
|
case 'P4-Personal': return '#A855F7';
|
|
default: return '#6c757d';
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'Complete': return '#28a745';
|
|
case 'In Progress': return '#4ECDC4';
|
|
case 'Blocked': return '#dc3545';
|
|
case 'Planned': return '#6c757d';
|
|
default: return '#6c757d';
|
|
}
|
|
};
|
|
|
|
const filteredTasks = tasks.filter(task => {
|
|
if (filter === 'all') return true;
|
|
if (filter === 'blocker') return task.frontmatter.blocker === true;
|
|
if (filter === 'active') return task.frontmatter.status === 'In Progress';
|
|
if (filter === 'complete') return task.frontmatter.status === 'Complete';
|
|
return true;
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '24px', marginBottom: '10px' }}>🔥❄️</div>
|
|
<div>Loading tasks...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={{ padding: '20px', textAlign: 'center', color: '#dc3545' }}>
|
|
<div style={{ fontSize: '24px', marginBottom: '10px' }}>⚠️</div>
|
|
<div>Error: {error}</div>
|
|
<button onClick={loadTasks} style={{ marginTop: '20px', padding: '10px 20px' }}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
maxWidth: '100%',
|
|
margin: '0',
|
|
padding: '0',
|
|
backgroundColor: '#f5f5f5',
|
|
minHeight: '100vh'
|
|
}}>
|
|
<div style={{
|
|
backgroundColor: '#0F0F1E',
|
|
color: 'white',
|
|
padding: '16px',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 100,
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
|
}}>
|
|
<h1 style={{ margin: '0 0 12px 0', fontSize: '20px', fontWeight: '700' }}>
|
|
🔥 Firefrost Tasks
|
|
</h1>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', overflowX: 'auto' }}>
|
|
{[
|
|
{ key: 'all', label: 'All', count: tasks.length },
|
|
{ key: 'blocker', label: 'Blockers', count: tasks.filter(t => t.frontmatter.blocker).length },
|
|
{ key: 'active', label: 'Active', count: tasks.filter(t => t.frontmatter.status === 'In Progress').length },
|
|
{ key: 'complete', label: 'Done', count: tasks.filter(t => t.frontmatter.status === 'Complete').length }
|
|
].map(({ key, label, count }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setFilter(key)}
|
|
style={{
|
|
padding: '8px 16px',
|
|
borderRadius: '20px',
|
|
border: 'none',
|
|
backgroundColor: filter === key ? '#4ECDC4' : 'rgba(255,255,255,0.2)',
|
|
color: 'white',
|
|
fontSize: '14px',
|
|
fontWeight: filter === key ? '600' : '400',
|
|
whiteSpace: 'nowrap',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
{label} ({count})
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ padding: '12px' }}>
|
|
{filteredTasks.map(task => (
|
|
<div
|
|
key={task.path}
|
|
style={{
|
|
backgroundColor: 'white',
|
|
borderRadius: '12px',
|
|
marginBottom: '12px',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
|
overflow: 'hidden'
|
|
}}
|
|
>
|
|
<div
|
|
onClick={() => setExpandedTask(expandedTask === task.path ? null : task.path)}
|
|
style={{
|
|
padding: '16px',
|
|
cursor: 'pointer',
|
|
borderLeft: `4px solid ${getPriorityColor(task.frontmatter.priority)}`
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
|
Task #{task.number}
|
|
</div>
|
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0F0F1E' }}>
|
|
{task.frontmatter.title}
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: '20px', marginLeft: '8px' }}>
|
|
{expandedTask === task.path ? '▼' : '▶'}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
<span style={{
|
|
display: 'inline-block',
|
|
padding: '4px 8px',
|
|
borderRadius: '12px',
|
|
fontSize: '11px',
|
|
fontWeight: '600',
|
|
backgroundColor: getPriorityColor(task.frontmatter.priority),
|
|
color: 'white'
|
|
}}>
|
|
{task.frontmatter.priority}
|
|
</span>
|
|
<span style={{
|
|
display: 'inline-block',
|
|
padding: '4px 8px',
|
|
borderRadius: '12px',
|
|
fontSize: '11px',
|
|
fontWeight: '600',
|
|
backgroundColor: getStatusColor(task.frontmatter.status),
|
|
color: 'white'
|
|
}}>
|
|
{task.frontmatter.status}
|
|
</span>
|
|
{task.frontmatter.blocker && (
|
|
<span style={{
|
|
display: 'inline-block',
|
|
padding: '4px 8px',
|
|
borderRadius: '12px',
|
|
fontSize: '11px',
|
|
fontWeight: '600',
|
|
backgroundColor: '#dc3545',
|
|
color: 'white'
|
|
}}>
|
|
🚨 BLOCKER
|
|
</span>
|
|
)}
|
|
<span style={{
|
|
display: 'inline-block',
|
|
padding: '4px 8px',
|
|
borderRadius: '12px',
|
|
fontSize: '11px',
|
|
backgroundColor: '#e9ecef',
|
|
color: '#495057'
|
|
}}>
|
|
{task.frontmatter.owner}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedTask === task.path && (
|
|
<div style={{
|
|
padding: '16px',
|
|
borderTop: '1px solid #e9ecef',
|
|
backgroundColor: '#f8f9fa'
|
|
}}>
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: '600', marginBottom: '8px', color: '#495057' }}>
|
|
Status
|
|
</label>
|
|
<select
|
|
value={task.frontmatter.status}
|
|
onChange={(e) => updateTaskField(task, 'status', e.target.value)}
|
|
disabled={saving}
|
|
style={{
|
|
width: '100%',
|
|
padding: '12px',
|
|
fontSize: '16px',
|
|
borderRadius: '8px',
|
|
border: '1px solid #dee2e6',
|
|
backgroundColor: 'white'
|
|
}}
|
|
>
|
|
<option value="Planned">Planned</option>
|
|
<option value="In Progress">In Progress</option>
|
|
<option value="Blocked">Blocked</option>
|
|
<option value="Complete">Complete</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: '600', marginBottom: '8px', color: '#495057' }}>
|
|
Priority
|
|
</label>
|
|
<select
|
|
value={task.frontmatter.priority}
|
|
onChange={(e) => updateTaskField(task, 'priority', e.target.value)}
|
|
disabled={saving}
|
|
style={{
|
|
width: '100%',
|
|
padding: '12px',
|
|
fontSize: '16px',
|
|
borderRadius: '8px',
|
|
border: '1px solid #dee2e6',
|
|
backgroundColor: 'white'
|
|
}}
|
|
>
|
|
<option value="P0-Blocker">P0 - Blocker</option>
|
|
<option value="P1-High">P1 - High</option>
|
|
<option value="P2-Medium">P2 - Medium</option>
|
|
<option value="P3-Low">P3 - Low</option>
|
|
<option value="P4-Personal">P4 - Personal</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: '600', marginBottom: '8px', color: '#495057' }}>
|
|
Owner
|
|
</label>
|
|
<select
|
|
value={task.frontmatter.owner}
|
|
onChange={(e) => updateTaskField(task, 'owner', e.target.value)}
|
|
disabled={saving}
|
|
style={{
|
|
width: '100%',
|
|
padding: '12px',
|
|
fontSize: '16px',
|
|
borderRadius: '8px',
|
|
border: '1px solid #dee2e6',
|
|
backgroundColor: 'white'
|
|
}}
|
|
>
|
|
<option value="Michael">Michael</option>
|
|
<option value="Meg">Meg</option>
|
|
<option value="Holly">Holly</option>
|
|
<option value="Trinity">Trinity</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', fontSize: '14px', fontWeight: '600', color: '#495057' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={task.frontmatter.blocker === true}
|
|
onChange={(e) => updateTaskField(task, 'blocker', e.target.checked)}
|
|
disabled={saving}
|
|
style={{ marginRight: '8px', width: '20px', height: '20px' }}
|
|
/>
|
|
Launch Blocker
|
|
</label>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: 'white', borderRadius: '8px', fontSize: '14px', lineHeight: '1.6', color: '#495057', whiteSpace: 'pre-wrap' }}>
|
|
{task.body.split('\n').slice(0, 5).join('\n')}
|
|
{task.body.split('\n').length > 5 && <div style={{ marginTop: '8px', fontStyle: 'italic', color: '#6c757d' }}>...</div>}
|
|
</div>
|
|
|
|
{saving && (
|
|
<div style={{ marginTop: '12px', textAlign: 'center', color: '#4ECDC4', fontSize: '14px' }}>
|
|
💾 Saving...
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{filteredTasks.length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: '40px', color: '#6c757d' }}>
|
|
No tasks found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<MobileTaskManager />);
|
|
</script>
|
|
</body>
|
|
</html>
|