Add mobile task manager as standalone HTML page
Path: firefrostgaming.com/tasks.html Standalone page (no 11ty layout) to avoid homepage redirect. Mobile-first task viewer/editor connecting to Gitea API.
This commit is contained in:
485
tasks.html
Normal file
485
tasks.html
Normal file
@@ -0,0 +1,485 @@
|
||||
<!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';
|
||||
|
||||
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] };
|
||||
};
|
||||
|
||||
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, 'P1': 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', fontSize: '16px', borderRadius: '8px', border: 'none', backgroundColor: '#4ECDC4', color: 'white', cursor: 'pointer' }}>
|
||||
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', paddingBottom: '4px' }}>
|
||||
{[
|
||||
{ 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>
|
||||
Reference in New Issue
Block a user