feat(timer): Timer/Countdown-Widget mit Presets und Alarm
Countdown-Timer als Single-Instance-Widget mit Preset-System (max. 5), Web Audio API Alarm und Tab-Titel-Blink bei Ablauf. Mute-Toggle zum Stummschalten des Alarms. Z-Index-Hierarchie für Widgets auf 100 angehoben.
This commit is contained in:
+210
-2
@@ -908,7 +908,7 @@ body.show-desc .bm-desc { display: block; }
|
||||
============================================ */
|
||||
.widget {
|
||||
position: fixed;
|
||||
z-index: 51;
|
||||
z-index: 100;
|
||||
min-width: 200px; min-height: 150px;
|
||||
background: var(--bg-board);
|
||||
border: 1px solid var(--border-accent);
|
||||
@@ -1156,6 +1156,214 @@ body.show-desc .bm-desc { display: block; }
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMER WIDGET
|
||||
============================================ */
|
||||
.timer-display {
|
||||
text-align: center;
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
.timer-time {
|
||||
font-size: 36px;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.timer-time.finished {
|
||||
color: var(--danger);
|
||||
animation: timer-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes timer-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.timer-input-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.timer-input {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
font-size: 16px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.timer-input::placeholder { color: var(--text-muted); }
|
||||
.timer-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.timer-ctrl-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.timer-ctrl-btn:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--border-accent);
|
||||
}
|
||||
.timer-ctrl-btn:active { transform: scale(0.95); }
|
||||
.timer-ctrl-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
.timer-ctrl-btn.primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timer-ctrl-btn.primary:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.timer-ctrl-btn.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.timer-mute-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
background: var(--bg-board);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.timer-mute-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.timer-mute-btn.muted {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.timer-presets {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 8px 0;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.timer-presets-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.timer-presets-title {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.timer-preset-add {
|
||||
width: 22px; height: 22px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.timer-preset-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.timer-preset-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.timer-preset-item:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.timer-preset-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timer-preset-time {
|
||||
color: var(--accent);
|
||||
font-family: 'Rajdhani', monospace;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.timer-preset-del {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.timer-preset-del:hover { color: var(--danger); }
|
||||
.timer-add-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.timer-add-input {
|
||||
flex: 1;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.timer-add-input::placeholder { color: var(--text-muted); }
|
||||
.timer-add-input:focus { outline: none; border-color: var(--accent); }
|
||||
.timer-add-confirm {
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.timer-add-confirm:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WIDGET TOOLBAR
|
||||
============================================ */
|
||||
@@ -1164,7 +1372,7 @@ body.show-desc .bm-desc { display: block; }
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 90;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ async function init() {
|
||||
await migrateSticky();
|
||||
await Notes.init();
|
||||
await Calculator.init();
|
||||
await Timer.init();
|
||||
initDataButtons();
|
||||
Store.checkQuota();
|
||||
|
||||
@@ -100,7 +101,8 @@ async function checkBackupReminder() {
|
||||
const widgetData = await Store.get('widgetStates');
|
||||
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
||||
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
||||
const data = { version: '1.7.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory };
|
||||
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
||||
const data = { version: '1.7.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
+18
-2
@@ -18,7 +18,8 @@ function initDataButtons() {
|
||||
boards,
|
||||
settings,
|
||||
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
|
||||
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : []
|
||||
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
|
||||
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -94,13 +95,28 @@ function initDataButtons() {
|
||||
}
|
||||
}
|
||||
|
||||
// Timer-Presets importieren (falls vorhanden)
|
||||
let timerImported = false;
|
||||
if (Array.isArray(data.timerPresets) && data.timerPresets.length > 0) {
|
||||
const validPresets = data.timerPresets.filter(p => p && typeof p.name === 'string' && typeof p.seconds === 'number');
|
||||
if (validPresets.length > 0) {
|
||||
if (!existingWidgets.timer) {
|
||||
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
|
||||
}
|
||||
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
|
||||
Timer._presets = existingWidgets.timer.presets;
|
||||
timerImported = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Gemeinsam speichern
|
||||
await Store.set('widgetStates', existingWidgets);
|
||||
|
||||
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
|
||||
const calcMsg = calcImported ? ' + Calculator-History' : '';
|
||||
const timerMsg = timerImported ? ' + Timer-Presets' : '';
|
||||
await HellionDialog.alert(
|
||||
`${validBoards.length} Board(s)${noteMsg}${calcMsg} erfolgreich importiert.`,
|
||||
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
|
||||
{ type: 'success', title: 'Import erfolgreich' }
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
+6
-1
@@ -44,12 +44,15 @@ const Notes = {
|
||||
return note;
|
||||
});
|
||||
|
||||
// Calculator-State beibehalten falls vorhanden
|
||||
// Calculator- und Timer-State beibehalten falls vorhanden
|
||||
const existing = await Store.get(this.STORAGE_KEY);
|
||||
const saveData = { notes: merged };
|
||||
if (existing && existing.calculator) {
|
||||
saveData.calculator = existing.calculator;
|
||||
}
|
||||
if (existing && existing.timer) {
|
||||
saveData.timer = existing.timer;
|
||||
}
|
||||
await Store.set(this.STORAGE_KEY, saveData);
|
||||
},
|
||||
|
||||
@@ -522,6 +525,8 @@ const Notes = {
|
||||
await this.create('checklist');
|
||||
} else if (action === 'calculator') {
|
||||
Calculator.toggle();
|
||||
} else if (action === 'timer') {
|
||||
Timer.toggle();
|
||||
} else if (action === 'notebook') {
|
||||
this.openNotebook();
|
||||
}
|
||||
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — timer.js
|
||||
Timer / Countdown Widget: Presets, Alarm,
|
||||
Tab-Titel-Blink
|
||||
============================================= */
|
||||
|
||||
const Timer = {
|
||||
WIDGET_ID: 'widget_timer',
|
||||
STORAGE_KEY: 'widgetStates',
|
||||
MAX_PRESETS: 5,
|
||||
|
||||
/** @type {Array<{name: string, seconds: number}>} */
|
||||
_presets: [],
|
||||
_isOpen: false,
|
||||
_seconds: 0,
|
||||
_remaining: 0,
|
||||
_intervalId: null,
|
||||
_running: false,
|
||||
_finished: false,
|
||||
_blinkIntervalId: null,
|
||||
_originalTitle: '',
|
||||
_keydownHandler: null,
|
||||
_muted: false,
|
||||
|
||||
// UI-Referenzen
|
||||
_timeEl: null,
|
||||
_muteBtn: null,
|
||||
_inputEl: null,
|
||||
_inputRow: null,
|
||||
_btnStart: null,
|
||||
_btnPause: null,
|
||||
_btnReset: null,
|
||||
|
||||
// ---- STORAGE ----
|
||||
|
||||
/**
|
||||
* Timer-State aus Storage laden
|
||||
*/
|
||||
async load() {
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (data && data.timer) {
|
||||
this._presets = Array.isArray(data.timer.presets) ? data.timer.presets : [];
|
||||
if (typeof data.timer.muted === 'boolean') this._muted = data.timer.muted;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer-State in Storage speichern
|
||||
* Bestehende Notes + Calculator bleiben erhalten
|
||||
*/
|
||||
async save() {
|
||||
const data = await Store.get(this.STORAGE_KEY) || {};
|
||||
if (data.notes === undefined) data.notes = [];
|
||||
|
||||
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||||
data.timer = {
|
||||
x: widgetState ? widgetState.x : 600,
|
||||
y: widgetState ? widgetState.y : 80,
|
||||
width: widgetState ? widgetState.width : 260,
|
||||
height: widgetState ? widgetState.height : 360,
|
||||
open: this._isOpen,
|
||||
presets: this._presets.slice(0, this.MAX_PRESETS),
|
||||
muted: this._muted
|
||||
};
|
||||
|
||||
await Store.set(this.STORAGE_KEY, data);
|
||||
},
|
||||
|
||||
// ---- WIDGET LIFECYCLE ----
|
||||
|
||||
/**
|
||||
* Timer-Widget oeffnen oder in Vordergrund bringen
|
||||
*/
|
||||
async open() {
|
||||
if (this._isOpen) {
|
||||
WidgetManager.bringToFront(this.WIDGET_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
const saved = (data && data.timer) ? data.timer : {};
|
||||
|
||||
WidgetManager.create('timer', {
|
||||
id: this.WIDGET_ID,
|
||||
title: 'Timer',
|
||||
x: saved.x || 600,
|
||||
y: saved.y || 80,
|
||||
width: saved.width || 260,
|
||||
height: saved.height || 360,
|
||||
open: true
|
||||
});
|
||||
|
||||
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||
if (body) this.renderBody(body);
|
||||
|
||||
this._isOpen = true;
|
||||
|
||||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||
if (entry) this._bindKeyboard(entry.el);
|
||||
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer toggle: oeffnen oder minimieren
|
||||
*/
|
||||
async toggle() {
|
||||
if (this._isOpen) {
|
||||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||
if (entry && entry.state.open) {
|
||||
await WidgetManager.minimize(this.WIDGET_ID);
|
||||
this._isOpen = false;
|
||||
await this.save();
|
||||
} else if (entry) {
|
||||
await WidgetManager.openWidget(this.WIDGET_ID);
|
||||
this._isOpen = true;
|
||||
await this.save();
|
||||
}
|
||||
} else {
|
||||
await this.open();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wird aufgerufen wenn Widget geschlossen wird
|
||||
*/
|
||||
async onClose() {
|
||||
this._isOpen = false;
|
||||
this._unbindKeyboard();
|
||||
this._stopCountdown();
|
||||
this._stopAlarm();
|
||||
this._timeEl = null;
|
||||
this._inputEl = null;
|
||||
this._inputRow = null;
|
||||
this._btnStart = null;
|
||||
this._btnPause = null;
|
||||
this._btnReset = null;
|
||||
this._muteBtn = null;
|
||||
await this.save();
|
||||
},
|
||||
|
||||
// ---- UI RENDERING ----
|
||||
|
||||
/**
|
||||
* Timer-Body rendern
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
renderBody(bodyEl) {
|
||||
bodyEl.textContent = '';
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
|
||||
// Display
|
||||
const display = document.createElement('div');
|
||||
display.className = 'timer-display';
|
||||
|
||||
const timeEl = document.createElement('div');
|
||||
timeEl.className = 'timer-time';
|
||||
timeEl.textContent = '00:00';
|
||||
this._timeEl = timeEl;
|
||||
display.appendChild(timeEl);
|
||||
|
||||
// Input
|
||||
const inputRow = document.createElement('div');
|
||||
inputRow.className = 'timer-input-row';
|
||||
this._inputRow = inputRow;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'timer-input';
|
||||
input.type = 'text';
|
||||
input.placeholder = 'mm:ss';
|
||||
input.maxLength = 8;
|
||||
this._inputEl = input;
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._applyInput();
|
||||
this._start();
|
||||
}
|
||||
});
|
||||
|
||||
inputRow.appendChild(input);
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'timer-controls';
|
||||
|
||||
const btnStart = document.createElement('button');
|
||||
btnStart.className = 'timer-ctrl-btn primary';
|
||||
btnStart.type = 'button';
|
||||
btnStart.textContent = 'Start';
|
||||
btnStart.addEventListener('click', () => {
|
||||
if (!this._running && this._remaining === 0) {
|
||||
this._applyInput();
|
||||
}
|
||||
this._start();
|
||||
});
|
||||
this._btnStart = btnStart;
|
||||
|
||||
const btnPause = document.createElement('button');
|
||||
btnPause.className = 'timer-ctrl-btn';
|
||||
btnPause.type = 'button';
|
||||
btnPause.textContent = 'Pause';
|
||||
btnPause.disabled = true;
|
||||
btnPause.addEventListener('click', () => this._pause());
|
||||
this._btnPause = btnPause;
|
||||
|
||||
const btnReset = document.createElement('button');
|
||||
btnReset.className = 'timer-ctrl-btn danger';
|
||||
btnReset.type = 'button';
|
||||
btnReset.textContent = 'Reset';
|
||||
btnReset.addEventListener('click', () => this._reset());
|
||||
this._btnReset = btnReset;
|
||||
|
||||
controls.append(btnStart, btnPause, btnReset);
|
||||
|
||||
// Mute Toggle (in Controls-Zeile)
|
||||
const muteBtn = document.createElement('button');
|
||||
muteBtn.className = 'timer-mute-btn';
|
||||
muteBtn.type = 'button';
|
||||
this._muteBtn = muteBtn;
|
||||
this._updateMuteBtn();
|
||||
muteBtn.addEventListener('click', async () => {
|
||||
this._muted = !this._muted;
|
||||
this._updateMuteBtn();
|
||||
await this.save();
|
||||
});
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
// Presets
|
||||
const presetsEl = this._createPresetsPanel();
|
||||
|
||||
bodyEl.append(display, inputRow, controls, presetsEl);
|
||||
|
||||
// State wiederherstellen
|
||||
this._updateDisplay();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Presets-Panel erstellen
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createPresetsPanel() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'timer-presets';
|
||||
container.id = 'timerPresetsPanel';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'timer-presets-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'timer-presets-title';
|
||||
title.textContent = 'Presets';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'timer-preset-add';
|
||||
addBtn.type = 'button';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.title = 'Preset speichern';
|
||||
addBtn.addEventListener('click', () => this._showAddPreset(container));
|
||||
|
||||
header.append(title, addBtn);
|
||||
container.appendChild(header);
|
||||
|
||||
this._renderPresetItems(container);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
/**
|
||||
* Preset-Items rendern
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
_renderPresetItems(container) {
|
||||
// Alte Items entfernen
|
||||
const oldItems = container.querySelectorAll('.timer-preset-item, .timer-add-row');
|
||||
oldItems.forEach(item => item.remove());
|
||||
|
||||
this._presets.forEach((preset, idx) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'timer-preset-item';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'timer-preset-name';
|
||||
name.textContent = preset.name;
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'timer-preset-time';
|
||||
time.textContent = this._formatTime(preset.seconds);
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.className = 'timer-preset-del';
|
||||
del.type = 'button';
|
||||
del.textContent = '\u2715';
|
||||
del.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this._deletePreset(idx);
|
||||
this._renderPresetItems(container);
|
||||
});
|
||||
|
||||
item.append(name, time, del);
|
||||
|
||||
// Klick laedt Preset
|
||||
item.addEventListener('click', () => {
|
||||
this._loadPreset(preset);
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add-Preset UI anzeigen
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
_showAddPreset(container) {
|
||||
// Nur einmal anzeigen
|
||||
if (container.querySelector('.timer-add-row')) return;
|
||||
|
||||
if (this._presets.length >= this.MAX_PRESETS) {
|
||||
HellionDialog.alert(
|
||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktuelle Zeit als Vorlage
|
||||
const currentSeconds = this._remaining > 0 ? this._seconds : 0;
|
||||
if (currentSeconds === 0 && this._inputEl) {
|
||||
const parsed = this._parseTimeInput(this._inputEl.value);
|
||||
if (parsed === 0) {
|
||||
HellionDialog.alert(
|
||||
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
||||
{ type: 'info', title: 'Keine Zeit' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'timer-add-row';
|
||||
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'timer-add-input';
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = 'Name...';
|
||||
nameInput.maxLength = 20;
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'timer-add-confirm';
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.textContent = 'OK';
|
||||
|
||||
const doAdd = async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
let secs = this._seconds;
|
||||
if (secs === 0 && this._inputEl) {
|
||||
secs = this._parseTimeInput(this._inputEl.value);
|
||||
}
|
||||
if (secs === 0) return;
|
||||
|
||||
await this._addPreset(name, secs);
|
||||
this._renderPresetItems(container);
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', doAdd);
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doAdd();
|
||||
if (e.key === 'Escape') row.remove();
|
||||
});
|
||||
|
||||
row.append(nameInput, confirmBtn);
|
||||
container.appendChild(row);
|
||||
nameInput.focus();
|
||||
},
|
||||
|
||||
// ---- TIMER LOGIC ----
|
||||
|
||||
/**
|
||||
* Input-Feld auslesen und als Sekunden setzen
|
||||
*/
|
||||
_applyInput() {
|
||||
if (!this._inputEl) return;
|
||||
const secs = this._parseTimeInput(this._inputEl.value);
|
||||
if (secs > 0) {
|
||||
this._seconds = secs;
|
||||
this._remaining = secs;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer starten
|
||||
*/
|
||||
_start() {
|
||||
if (this._running) return;
|
||||
if (this._remaining <= 0) return;
|
||||
|
||||
// Falls gerade Alarm laeuft, stoppen
|
||||
if (this._finished) {
|
||||
this._stopAlarm();
|
||||
this._finished = false;
|
||||
}
|
||||
|
||||
this._running = true;
|
||||
this._updateControls();
|
||||
|
||||
// Input verstecken
|
||||
if (this._inputRow) this._inputRow.style.display = 'none';
|
||||
|
||||
this._intervalId = setInterval(() => this._tick(), 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer pausieren
|
||||
*/
|
||||
_pause() {
|
||||
if (!this._running) return;
|
||||
this._running = false;
|
||||
this._stopCountdown();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer zuruecksetzen
|
||||
*/
|
||||
_reset() {
|
||||
this._stopCountdown();
|
||||
this._stopAlarm();
|
||||
this._running = false;
|
||||
this._finished = false;
|
||||
this._remaining = 0;
|
||||
this._seconds = 0;
|
||||
|
||||
// Input wieder anzeigen
|
||||
if (this._inputRow) this._inputRow.style.display = 'flex';
|
||||
if (this._inputEl) this._inputEl.value = '';
|
||||
|
||||
this._updateDisplay();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Jede Sekunde: remaining verringern, Display aktualisieren
|
||||
*/
|
||||
_tick() {
|
||||
this._remaining--;
|
||||
|
||||
if (this._remaining <= 0) {
|
||||
this._remaining = 0;
|
||||
this._stopCountdown();
|
||||
this._running = false;
|
||||
this._finished = true;
|
||||
this._onFinish();
|
||||
}
|
||||
|
||||
this._updateDisplay();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Interval stoppen
|
||||
*/
|
||||
_stopCountdown() {
|
||||
if (this._intervalId) {
|
||||
clearInterval(this._intervalId);
|
||||
this._intervalId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Timer abgelaufen — Alarm + Tab-Blink
|
||||
*/
|
||||
_onFinish() {
|
||||
if (!this._muted) this._playAlarm();
|
||||
this._startTitleBlink();
|
||||
},
|
||||
|
||||
/**
|
||||
* Akustisches Signal (Browser Audio API, kein externer Request)
|
||||
*/
|
||||
_playAlarm() {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
[0, 0.3, 0.6].forEach(delay => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 800;
|
||||
gain.gain.value = 0.07;
|
||||
osc.start(ctx.currentTime + delay);
|
||||
osc.stop(ctx.currentTime + delay + 0.2);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Timer: Audio nicht verfuegbar', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tab-Titel blinken lassen
|
||||
*/
|
||||
_startTitleBlink() {
|
||||
this._originalTitle = document.title;
|
||||
this._blinkIntervalId = setInterval(() => {
|
||||
document.title = document.title === '[!] Timer abgelaufen'
|
||||
? this._originalTitle
|
||||
: '[!] Timer abgelaufen';
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Tab-Titel Blink und Alarm stoppen
|
||||
*/
|
||||
_stopAlarm() {
|
||||
if (this._blinkIntervalId) {
|
||||
clearInterval(this._blinkIntervalId);
|
||||
this._blinkIntervalId = null;
|
||||
document.title = this._originalTitle || 'Hellion Dashboard';
|
||||
}
|
||||
this._finished = false;
|
||||
this._updateDisplay();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Mute-Button Text/Titel aktualisieren
|
||||
*/
|
||||
_updateMuteBtn() {
|
||||
if (!this._muteBtn) return;
|
||||
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
||||
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
|
||||
this._muteBtn.classList.toggle('muted', this._muted);
|
||||
},
|
||||
|
||||
// ---- DISPLAY ----
|
||||
|
||||
/**
|
||||
* Zeitanzeige aktualisieren
|
||||
*/
|
||||
_updateDisplay() {
|
||||
if (!this._timeEl) return;
|
||||
this._timeEl.textContent = this._formatTime(this._remaining);
|
||||
this._timeEl.classList.toggle('finished', this._finished);
|
||||
},
|
||||
|
||||
/**
|
||||
* Button-States aktualisieren
|
||||
*/
|
||||
_updateControls() {
|
||||
if (this._btnStart) {
|
||||
this._btnStart.disabled = this._running;
|
||||
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
|
||||
}
|
||||
if (this._btnPause) {
|
||||
this._btnPause.disabled = !this._running;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- PRESETS ----
|
||||
|
||||
/**
|
||||
* Preset hinzufuegen
|
||||
* @param {string} name
|
||||
* @param {number} seconds
|
||||
*/
|
||||
async _addPreset(name, seconds) {
|
||||
if (this._presets.length >= this.MAX_PRESETS) return;
|
||||
this._presets.push({ name, seconds });
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Preset loeschen
|
||||
* @param {number} index
|
||||
*/
|
||||
async _deletePreset(index) {
|
||||
this._presets.splice(index, 1);
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Preset laden (Zeit setzen)
|
||||
* @param {Object} preset - { name, seconds }
|
||||
*/
|
||||
_loadPreset(preset) {
|
||||
// Falls laufend, erst stoppen
|
||||
this._stopCountdown();
|
||||
this._stopAlarm();
|
||||
this._running = false;
|
||||
this._finished = false;
|
||||
|
||||
this._seconds = preset.seconds;
|
||||
this._remaining = preset.seconds;
|
||||
|
||||
if (this._inputRow) this._inputRow.style.display = 'none';
|
||||
|
||||
this._updateDisplay();
|
||||
this._updateControls();
|
||||
},
|
||||
|
||||
// ---- FORMATTING ----
|
||||
|
||||
/**
|
||||
* Sekunden in Zeitformat umwandeln
|
||||
* @param {number} totalSeconds
|
||||
* @returns {string} "05:30" oder "1:05:30"
|
||||
*/
|
||||
_formatTime(totalSeconds) {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const ss = String(s).padStart(2, '0');
|
||||
|
||||
if (h > 0) {
|
||||
return h + ':' + mm + ':' + ss;
|
||||
}
|
||||
return mm + ':' + ss;
|
||||
},
|
||||
|
||||
/**
|
||||
* Zeit-String in Sekunden parsen
|
||||
* Akzeptiert: "5:30", "05:30", "1:05:30", "90" (Sekunden)
|
||||
* @param {string} str
|
||||
* @returns {number}
|
||||
*/
|
||||
_parseTimeInput(str) {
|
||||
const trimmed = (str || '').trim();
|
||||
if (!trimmed) return 0;
|
||||
|
||||
const parts = trimmed.split(':');
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Nur Zahl = Sekunden
|
||||
const secs = parseInt(parts[0], 10);
|
||||
return isNaN(secs) ? 0 : Math.max(0, secs);
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
// mm:ss
|
||||
const m = parseInt(parts[0], 10);
|
||||
const s = parseInt(parts[1], 10);
|
||||
if (isNaN(m) || isNaN(s)) return 0;
|
||||
return Math.max(0, m * 60 + s);
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
// hh:mm:ss
|
||||
const h = parseInt(parts[0], 10);
|
||||
const m = parseInt(parts[1], 10);
|
||||
const s = parseInt(parts[2], 10);
|
||||
if (isNaN(h) || isNaN(m) || isNaN(s)) return 0;
|
||||
return Math.max(0, h * 3600 + m * 60 + s);
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
|
||||
// ---- KEYBOARD ----
|
||||
|
||||
/**
|
||||
* Tastatur-Events binden
|
||||
* @param {HTMLElement} widgetEl
|
||||
*/
|
||||
_bindKeyboard(widgetEl) {
|
||||
this._unbindKeyboard();
|
||||
|
||||
this._keydownHandler = (e) => {
|
||||
// Nicht reagieren wenn User in Input tippt
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this._running) {
|
||||
this._pause();
|
||||
} else if (this._remaining > 0) {
|
||||
this._start();
|
||||
}
|
||||
} else if (e.key === 'Escape' || e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault();
|
||||
this._reset();
|
||||
}
|
||||
};
|
||||
|
||||
widgetEl.addEventListener('keydown', this._keydownHandler);
|
||||
widgetEl.tabIndex = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keyboard-Events entfernen
|
||||
*/
|
||||
_unbindKeyboard() {
|
||||
if (this._keydownHandler) {
|
||||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||
if (entry) {
|
||||
entry.el.removeEventListener('keydown', this._keydownHandler);
|
||||
}
|
||||
this._keydownHandler = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- INIT ----
|
||||
|
||||
/**
|
||||
* Timer initialisieren (aus app.js aufgerufen)
|
||||
*/
|
||||
async init() {
|
||||
await this.load();
|
||||
|
||||
// Wenn Timer beim letzten Mal offen war, wiederherstellen
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (data && data.timer && data.timer.open) {
|
||||
await this.open();
|
||||
}
|
||||
|
||||
// Close-Event abfangen
|
||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||
const self = this;
|
||||
const prevClose = WidgetManager.close;
|
||||
WidgetManager.close = function(id) {
|
||||
prevClose.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Minimize-Event abfangen
|
||||
const prevMinimize = WidgetManager.minimize;
|
||||
WidgetManager.minimize = async function(id) {
|
||||
await prevMinimize.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
await self.save();
|
||||
}
|
||||
};
|
||||
|
||||
// Open-Event abfangen
|
||||
const prevOpen = WidgetManager.openWidget;
|
||||
WidgetManager.openWidget = async function(id) {
|
||||
await prevOpen.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
await self.save();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
const WidgetManager = {
|
||||
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
|
||||
_widgets: new Map(),
|
||||
_topZ: 51,
|
||||
_topZ: 100,
|
||||
STORAGE_KEY: 'widgetStates',
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user