Files
woke/index.html
2026-02-25 19:39:39 +02:00

1005 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Woke - Alarm Clock</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--color-bg: #1a1a1a;
--color-surface: #2a2a2a;
--color-clock: #00ff88;
--color-alarm: #ff4444;
--color-text: #e0e0e0;
--color-text-dim: #888;
--color-border: #404040;
}
body {
font-family: 'Courier New', monospace;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
overflow-x: hidden;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 15px;
}
/* Clock Display */
.clock-container {
text-align: center;
padding: 20px 15px;
background: var(--color-surface);
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.big-clock {
font-family: monospace;
font-size: 42px;
font-weight: bold;
color: var(--color-clock);
line-height: 1.2;
white-space: pre;
display: inline-block;
text-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
}
.big-clock.firing {
color: var(--color-alarm);
animation: pulse 1s infinite;
text-shadow: 0 0 30px rgba(255, 68, 68, 0.8);
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}
.current-date {
font-size: 14px;
color: var(--color-text-dim);
margin-top: 8px;
}
/* Buttons */
.btn {
background: var(--color-clock);
color: var(--color-bg);
border: none;
padding: 8px 16px;
font-size: 14px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: 'Courier New', monospace;
transition: all 0.2s;
text-transform: uppercase;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn-danger {
background: var(--color-alarm);
color: white;
}
.btn-danger:hover {
box-shadow: 0 4px 12px rgba(255, 68, 68, 0.4);
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 2px solid var(--color-border);
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
/* Alarm List */
.alarms-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: var(--color-clock);
text-transform: uppercase;
}
.alarm-list {
display: grid;
gap: 10px;
}
.alarm-card {
background: var(--color-surface);
padding: 15px;
border-radius: 6px;
border-left: 3px solid var(--color-border);
transition: all 0.2s;
}
.alarm-card.enabled {
border-left-color: var(--color-clock);
}
.alarm-card.disabled {
opacity: 0.6;
}
.alarm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.alarm-time-row {
display: flex;
align-items: center;
gap: 10px;
}
.alarm-time {
font-size: 28px;
font-weight: bold;
color: var(--color-clock);
}
.alarm-card.disabled .alarm-time {
color: var(--color-text-dim);
}
.alarm-actions {
display: flex;
gap: 6px;
}
.alarm-info {
margin-bottom: 8px;
}
.alarm-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 3px;
}
.alarm-desc {
color: var(--color-text-dim);
font-size: 13px;
}
.alarm-trigger {
display: inline-block;
padding: 3px 8px;
background: var(--color-bg);
border-radius: 3px;
font-size: 11px;
color: var(--color-text-dim);
white-space: nowrap;
}
/* Form */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
overflow-y: auto;
}
.modal.active {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal-content {
background: var(--color-surface);
padding: 20px;
border-radius: 8px;
max-width: 500px;
width: 100%;
border: 2px solid var(--color-clock);
}
.modal-header {
font-size: 20px;
font-weight: bold;
color: var(--color-clock);
margin-bottom: 15px;
text-transform: uppercase;
}
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-weight: bold;
color: var(--color-text);
font-size: 13px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 8px;
background: var(--color-bg);
border: 2px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--color-clock);
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.form-error {
color: var(--color-alarm);
font-size: 12px;
margin-top: 4px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.form-hint {
font-size: 12px;
color: var(--color-text-dim);
margin-top: 4px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
/* Firing Alarm Overlay */
.firing-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 2000;
justify-content: center;
align-items: center;
flex-direction: column;
}
.firing-overlay.active {
display: flex;
}
.firing-content {
text-align: center;
}
.firing-time {
font-size: 64px;
font-weight: bold;
color: var(--color-alarm);
margin-bottom: 15px;
animation: pulse 1s infinite;
}
.firing-name {
font-size: 32px;
margin-bottom: 8px;
color: var(--color-text);
}
.firing-desc {
font-size: 16px;
color: var(--color-text-dim);
margin-bottom: 30px;
}
.firing-actions {
display: flex;
gap: 15px;
justify-content: center;
}
.firing-actions .btn {
font-size: 18px;
padding: 15px 30px;
}
/* Status Message */
.status-bar {
position: fixed;
bottom: 15px;
right: 15px;
background: var(--color-surface);
padding: 12px 18px;
border-radius: 6px;
border-left: 3px solid var(--color-clock);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
z-index: 1500;
display: none;
max-width: 350px;
font-size: 14px;
}
.status-bar.active {
display: block;
animation: slideIn 0.3s ease-out;
}
.status-bar.error {
border-left-color: var(--color-alarm);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Loading State */
.loading {
text-align: center;
padding: 40px;
color: var(--color-text-dim);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--color-text-dim);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 12px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
transition: 0.3s;
border-radius: 28px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--color-clock);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
/* Responsive */
@media (max-width: 768px) {
.big-clock {
font-size: 36px;
}
.alarm-time {
font-size: 22px;
}
.firing-time {
font-size: 52px;
}
.firing-name {
font-size: 26px;
}
.settings-grid {
grid-template-columns: 1fr;
}
.container {
padding: 10px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Clock Display -->
<div class="clock-container">
<div id="bigClock" class="big-clock"></div>
<div class="current-date" id="currentDate"></div>
</div>
<!-- Alarms Section -->
<div class="alarms-section">
<div class="section-header">
<div class="section-title">Alarms</div>
<button class="btn" onclick="app.openAddModal()">+ Add Alarm</button>
</div>
<div id="alarmList" class="alarm-list">
<div class="loading">Loading alarms...</div>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="alarmModal" class="modal">
<div class="modal-content">
<div class="modal-header" id="modalTitle">Add Alarm</div>
<form id="alarmForm">
<input type="hidden" id="alarmId">
<div class="form-group">
<label class="form-label">Name *</label>
<input type="text" id="alarmName" class="form-input" required>
<div class="form-error" id="nameError"></div>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea id="alarmDescription" class="form-textarea"></textarea>
</div>
<div class="form-group">
<label class="form-label">Time (HH:MM) *</label>
<input type="text" id="alarmTime" class="form-input" placeholder="07:30" required pattern="[0-2][0-9]:[0-5][0-9]">
<div class="form-hint">24-hour format</div>
<div class="form-error" id="timeError"></div>
</div>
<div class="form-group">
<label class="form-label">Trigger Type *</label>
<select id="alarmTrigger" class="form-select" onchange="app.updateTriggerHelp()">
<option value="once">Once (one-time alarm)</option>
<option value="cron">Cron (repeating)</option>
</select>
</div>
<div class="form-group" id="cronGroup" style="display: none;">
<label class="form-label">Cron Expression</label>
<input type="text" id="cronExpression" class="form-input" placeholder="0 7 * * 1-5">
<div class="form-hint" id="cronHint">
Format: MIN HOUR DAY MONTH WEEKDAY<br>
Example: "0 7 * * 1-5" = 7:00 AM weekdays
</div>
<div class="form-error" id="cronError"></div>
</div>
<div class="form-group">
<label class="form-label">Sound Path</label>
<input type="text" id="alarmSound" class="form-input" placeholder="default">
<div class="form-hint">Leave as "default" for system sound</div>
</div>
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="alarmEnabled" checked>
<span class="form-label" style="margin: 0;">Enabled</span>
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button type="submit" class="btn">Save</button>
</div>
</form>
</div>
</div>
<!-- Firing Alarm Overlay -->
<div id="firingOverlay" class="firing-overlay">
<div class="firing-content">
<div class="firing-time" id="firingTime"></div>
<div class="firing-name" id="firingName"></div>
<div class="firing-desc" id="firingDesc"></div>
<div class="firing-actions">
<button class="btn btn-danger" onclick="app.dismissAlarm()">Dismiss</button>
<button class="btn" onclick="app.snoozeAlarm()">Snooze (5 min)</button>
</div>
</div>
</div>
<!-- Status Bar -->
<div id="statusBar" class="status-bar">
<div id="statusMessage"></div>
</div>
<!-- Audio element for alarm sound -->
<audio id="alarmAudio" loop></audio>
<script>
// ============================================================
// CONFIG - Edit these values to customize your setup
// ============================================================
const CONFIG = {
SERVER_URL: 'http://localhost:9119', // API server URL
API_PASSWORD: 'test', // Leave empty if no auth
SHOW_SECONDS: true, // Show seconds on clock
SOUND_URL: '', // URL to alarm sound (leave empty for silent)
};
// ============================================================
// Main Application
const app = {
apiUrl: CONFIG.SERVER_URL,
apiPassword: CONFIG.API_PASSWORD,
alarms: [],
currentAlarm: null,
firingAlarm: null,
audio: null,
clockInterval: null,
pollInterval: null,
showSeconds: CONFIG.SHOW_SECONDS,
init() {
this.audio = document.getElementById('alarmAudio');
this.startClock();
this.loadAlarms();
this.startPolling();
this.setupEventListeners();
},
setupEventListeners() {
// Form submission
document.getElementById('alarmForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveAlarm();
});
// Close modal on outside click
document.getElementById('alarmModal').addEventListener('click', (e) => {
if (e.target.id === 'alarmModal') {
this.closeModal();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
},
async apiRequest(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
};
if (this.apiPassword) {
headers['Authorization'] = `Bearer ${this.apiPassword}`;
}
const response = await fetch(url, {
...options,
headers: { ...headers, ...options.headers },
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Request failed');
}
if (response.status === 204) {
return null;
}
return response.json();
},
async loadAlarms() {
try {
const alarms = await this.apiRequest('/api/alarms');
this.alarms = alarms;
this.renderAlarms();
this.checkAlarms();
} catch (error) {
console.error('Failed to load alarms:', error);
this.showStatus('Failed to load alarms: ' + error.message, true);
}
},
renderAlarms() {
const container = document.getElementById('alarmList');
if (this.alarms.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⏰</div>
<div>No alarms yet. Click "Add Alarm" to create one.</div>
</div>
`;
return;
}
container.innerHTML = this.alarms.map(alarm => `
<div class="alarm-card ${alarm.enabled ? 'enabled' : 'disabled'}">
<div class="alarm-header">
<div class="alarm-time-row">
<div class="alarm-time">${alarm.time || '—'}</div>
<span class="alarm-trigger">${alarm.trigger === 'once' ? 'One-time' : alarm.trigger}</span>
</div>
<div class="alarm-actions">
<label class="toggle-switch">
<input type="checkbox" ${alarm.enabled ? 'checked' : ''}
onchange="app.toggleAlarm(${alarm.id})">
<span class="toggle-slider"></span>
</label>
<button class="btn btn-small btn-secondary" onclick="app.openEditModal(${alarm.id})">Edit</button>
<button class="btn btn-small btn-danger" onclick="app.deleteAlarm(${alarm.id})">Delete</button>
</div>
</div>
<div class="alarm-info">
<div class="alarm-name">${this.escapeHtml(alarm.name)}</div>
${alarm.description ? `<div class="alarm-desc">${this.escapeHtml(alarm.description)}</div>` : ''}
</div>
</div>
`).join('');
},
openAddModal() {
document.getElementById('modalTitle').textContent = 'Add Alarm';
document.getElementById('alarmId').value = '';
document.getElementById('alarmForm').reset();
document.getElementById('alarmEnabled').checked = true;
document.getElementById('alarmSound').value = 'default';
document.getElementById('alarmTrigger').value = 'once';
this.updateTriggerHelp();
document.getElementById('alarmModal').classList.add('active');
},
openEditModal(id) {
const alarm = this.alarms.find(a => a.id === id);
if (!alarm) return;
document.getElementById('modalTitle').textContent = 'Edit Alarm';
document.getElementById('alarmId').value = alarm.id;
document.getElementById('alarmName').value = alarm.name;
document.getElementById('alarmDescription').value = alarm.description || '';
document.getElementById('alarmTime').value = alarm.time || '';
document.getElementById('alarmSound').value = alarm.sound_path || 'default';
document.getElementById('alarmEnabled').checked = alarm.enabled;
if (alarm.trigger === 'once') {
document.getElementById('alarmTrigger').value = 'once';
} else {
document.getElementById('alarmTrigger').value = 'cron';
document.getElementById('cronExpression').value = alarm.trigger;
}
this.updateTriggerHelp();
document.getElementById('alarmModal').classList.add('active');
},
closeModal() {
document.getElementById('alarmModal').classList.remove('active');
this.clearFormErrors();
},
updateTriggerHelp() {
const trigger = document.getElementById('alarmTrigger').value;
const cronGroup = document.getElementById('cronGroup');
cronGroup.style.display = trigger === 'cron' ? 'block' : 'none';
},
clearFormErrors() {
['nameError', 'timeError', 'cronError'].forEach(id => {
document.getElementById(id).textContent = '';
});
},
validateForm() {
this.clearFormErrors();
let valid = true;
const name = document.getElementById('alarmName').value.trim();
if (!name) {
document.getElementById('nameError').textContent = 'Name is required';
valid = false;
}
const time = document.getElementById('alarmTime').value.trim();
if (!time.match(/^[0-2][0-9]:[0-5][0-9]$/)) {
document.getElementById('timeError').textContent = 'Invalid time format (use HH:MM)';
valid = false;
}
const trigger = document.getElementById('alarmTrigger').value;
if (trigger === 'cron') {
const cron = document.getElementById('cronExpression').value.trim();
if (!cron) {
document.getElementById('cronError').textContent = 'Cron expression required';
valid = false;
}
}
return valid;
},
async saveAlarm() {
if (!this.validateForm()) {
return;
}
const id = document.getElementById('alarmId').value;
const trigger = document.getElementById('alarmTrigger').value;
const data = {
name: document.getElementById('alarmName').value.trim(),
description: document.getElementById('alarmDescription').value.trim(),
time: document.getElementById('alarmTime').value.trim(),
trigger: trigger === 'once' ? 'once' : document.getElementById('cronExpression').value.trim(),
sound_path: document.getElementById('alarmSound').value.trim() || 'default',
enabled: document.getElementById('alarmEnabled').checked,
};
try {
if (id) {
await this.apiRequest(`/api/alarms/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
this.showStatus('Alarm updated');
} else {
await this.apiRequest('/api/alarms', {
method: 'POST',
body: JSON.stringify(data),
});
this.showStatus('Alarm created');
}
this.closeModal();
await this.loadAlarms();
} catch (error) {
this.showStatus('Failed to save alarm: ' + error.message, true);
}
},
async toggleAlarm(id) {
try {
await this.apiRequest(`/api/alarms/${id}/toggle`, {
method: 'PATCH',
});
await this.loadAlarms();
} catch (error) {
this.showStatus('Failed to toggle alarm: ' + error.message, true);
}
},
async deleteAlarm(id) {
if (!confirm('Delete this alarm?')) {
return;
}
try {
await this.apiRequest(`/api/alarms/${id}`, {
method: 'DELETE',
});
this.showStatus('Alarm deleted');
await this.loadAlarms();
} catch (error) {
this.showStatus('Failed to delete alarm: ' + error.message, true);
}
},
startClock() {
const updateClock = () => {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
const clockElem = document.getElementById('bigClock');
const colonVisible = Math.floor(now.getTime() / 1000) % 2 === 0;
if (this.showSeconds) {
clockElem.textContent = `${h}${colonVisible ? ':' : ' '}${m}${colonVisible ? ':' : ' '}${s}`;
} else {
clockElem.textContent = `${h}${colonVisible ? ':' : ' '}${m}`;
}
// Update date
const dateStr = now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
document.getElementById('currentDate').textContent = dateStr;
};
updateClock();
this.clockInterval = setInterval(updateClock, 100);
},
startPolling() {
// Poll for alarm updates every 5 seconds
this.pollInterval = setInterval(() => {
this.loadAlarms();
}, 5000);
},
checkAlarms() {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
for (const alarm of this.alarms) {
if (alarm.enabled && alarm.time === currentTime && alarm.trigger === 'once') {
// Check if we haven't fired this alarm in the last minute
if (!alarm.last_triggered || new Date(alarm.last_triggered) < new Date(now - 60000)) {
this.fireAlarm(alarm);
break; // Only fire one alarm at a time
}
}
}
},
fireAlarm(alarm) {
this.firingAlarm = alarm;
document.getElementById('firingTime').textContent = alarm.time;
document.getElementById('firingName').textContent = alarm.name;
document.getElementById('firingDesc').textContent = alarm.description || '';
document.getElementById('firingOverlay').classList.add('active');
document.getElementById('bigClock').classList.add('firing');
// Play sound
if (CONFIG.SOUND_URL) {
this.audio.src = CONFIG.SOUND_URL;
this.audio.play().catch(err => {
console.error('Failed to play sound:', err);
});
}
},
dismissAlarm() {
this.stopFiring();
this.showStatus('Alarm dismissed');
this.loadAlarms(); // Reload to update status
},
snoozeAlarm() {
this.stopFiring();
this.showStatus('Alarm snoozed for 5 minutes');
// Set a timer to fire again in 5 minutes
setTimeout(() => {
if (this.firingAlarm) {
this.fireAlarm(this.firingAlarm);
}
}, 5 * 60 * 1000);
},
stopFiring() {
document.getElementById('firingOverlay').classList.remove('active');
document.getElementById('bigClock').classList.remove('firing');
this.audio.pause();
this.audio.currentTime = 0;
this.firingAlarm = null;
},
showStatus(message, isError = false) {
const statusBar = document.getElementById('statusBar');
const statusMessage = document.getElementById('statusMessage');
statusMessage.textContent = message;
statusBar.classList.toggle('error', isError);
statusBar.classList.add('active');
setTimeout(() => {
statusBar.classList.remove('active');
}, 3000);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// Initialize app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
} else {
app.init();
}
</script>
</body>
</html>