Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
82 lines (82 loc) · 23.1 KB
/
Copy pathapp.js
File metadata and controls
82 lines (82 loc) · 23.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/* DeskOS V1: lightweight local-first dashboard */
(function () {
'use strict';
var KEY = 'deskos-v1';
var seed = {
settings: { name: 'Alex', location: 'Melbourne', greeting: 'Ready for a good day?', theme: 'dark', hour24: false, showWeather: true, showTodo: true, showSchool: true, showMovie: true, showGaming: true, night: false },
weather: { temperature: 18, condition: 'Partly cloudy', icon: '⛅', source: 'Demo weather' },
tasks: [
{ id: 't1', title: 'Finish Technology project', priority: 'high', due: '', done: false },
{ id: 't2', title: 'Pack school bag', priority: 'medium', due: '', done: false },
{ id: 't3', title: 'Do Maths homework', priority: 'low', due: '', done: false }
],
school: [{ id: 's1', subject: 'Science', time: '10:45', room: 'Room 23', notes: 'Sample class — edit me' }, { id: 's2', subject: 'Mathematics', time: '13:20', room: 'Room 11', notes: '' }],
movie: { title: 'Dune', rating: '8.7', runtime: '2h 35m', notes: 'Sample movie — edit me', poster: '' },
gaming: { game: 'Stardew Valley', goal: 'Make 170 Starfruit Wine', progress: 42 }
};
function copy(value) { return JSON.parse(JSON.stringify(value)); }
var state;
try { state = JSON.parse(localStorage.getItem(KEY)) || copy(seed); } catch (e) { state = copy(seed); }
state.settings = Object.assign({}, seed.settings, state.settings || {});
var app = document.getElementById('app'), backdrop = document.getElementById('modalBackdrop');
var current = 'home';
state.weather = state.weather || {};
var WEATHER_TTL = 20 * 60 * 1000;
function save() { localStorage.setItem(KEY, JSON.stringify(state)); }
function normaliseLocation(location) { return String(location || '').trim().toLowerCase(); }
function hasWeatherData(weather) { return weather && typeof weather.temperature === 'number' && typeof weather.timestamp === 'number'; }
function weatherIsFresh(weather) { return hasWeatherData(weather) && weather.location === normaliseLocation(state.settings.location) && Date.now() - weather.timestamp < WEATHER_TTL; }
function weatherCondition(code, isDay) { var map={0:['Clear',isDay===0?'🌙':'☀️'],1:['Mostly clear',isDay===0?'🌙':'🌤️'],2:['Partly cloudy','⛅'],3:['Cloudy','☁️'],45:['Fog','🌫️'],48:['Rime fog','🌫️'],51:['Light drizzle','🌦️'],53:['Drizzle','🌦️'],55:['Heavy drizzle','🌧️'],56:['Freezing drizzle','🌧️'],57:['Freezing drizzle','🌧️'],61:['Light rain','🌦️'],63:['Rain','🌧️'],65:['Heavy rain','🌧️'],66:['Freezing rain','🌧️'],67:['Freezing rain','🌧️'],71:['Light snow','🌨️'],73:['Snow','❄️'],75:['Heavy snow','❄️'],77:['Snow grains','❄️'],80:['Rain showers','🌦️'],81:['Rain showers','🌧️'],82:['Heavy showers','🌧️'],85:['Snow showers','🌨️'],86:['Heavy snow showers','❄️'],95:['Thunderstorm','⛈️'],96:['Thunderstorm with hail','⛈️'],99:['Thunderstorm with hail','⛈️']}; return map[code] || ['Weather','🌡️']; }
function updatedLabel(timestamp) { if (!timestamp) return ''; var minutes=Math.max(0,Math.round((Date.now()-timestamp)/60000)); return minutes < 2 ? 'Updated just now' : 'Updated ' + minutes + 'm ago'; }
function renderWeather() { var old=app.querySelector('.weather'); if (old) old.outerHTML=weatherHtml(); }
function setWeatherUnavailable(message) { var old=state.weather || {}; state.weather={location:normaliseLocation(state.settings.location),locationLabel:state.settings.location,status:'error',error:message || 'Weather unavailable',timestamp:old.timestamp}; save(); renderWeather(); }
function fetchWeather(force) { var location=normaliseLocation(state.settings.location), cached=state.weather || {}; if (!location) { setWeatherUnavailable('Add a location in Settings'); return; } if (!force && weatherIsFresh(cached)) { renderWeather(); return; } if (cached.location !== location) cached={location:location,locationLabel:state.settings.location}; cached.status='loading'; cached.error=''; state.weather=cached; renderWeather(); var geocode='https://geocoding-api.open-meteo.com/v1/search?name='+encodeURIComponent(state.settings.location)+'&count=1&language=en&format=json'; fetch(geocode).then(function(response){if(!response.ok)throw new Error('Location lookup failed');return response.json();}).then(function(places){if(!places.results || !places.results.length)throw new Error('Location not found');var place=places.results[0], forecast='https://api.open-meteo.com/v1/forecast?latitude='+encodeURIComponent(place.latitude)+'&longitude='+encodeURIComponent(place.longitude)+'¤t=temperature_2m,weather_code,is_day&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&temperature_unit=celsius&timezone=auto&forecast_days=1';return fetch(forecast).then(function(response){if(!response.ok)throw new Error('Forecast unavailable');return response.json();}).then(function(data){return {place:place,data:data};});}).then(function(result){var currentWeather=result.data.current, daily=result.data.daily, condition=weatherCondition(currentWeather.weather_code,currentWeather.is_day);state.weather={location:location,locationLabel:result.place.name+(result.place.country?', '+result.place.country:''),temperature:Math.round(currentWeather.temperature_2m),condition:condition[0],icon:condition[1],high:Math.round(daily.temperature_2m_max[0]),low:Math.round(daily.temperature_2m_min[0]),rain:Math.round(daily.precipitation_probability_max[0] || 0),timestamp:Date.now(),status:'ready'};save();renderWeather();}).catch(function(){var old=state.weather || {};if(hasWeatherData(old) && old.location===location){old.status='stale';old.error='Offline · showing saved weather';state.weather=old;save();renderWeather();}else{setWeatherUnavailable('Weather unavailable');}}); }
function esc(text) { return String(text || '').replace(/[&<>"']/g, function (m) { return ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[m]; }); }
function uid(prefix) { return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function today() { return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
function timeText() { return new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: !state.settings.hour24 }); }
function card(cls, inner, enabled) { return enabled === false ? '' : '<section class="card ' + cls + '">' + inner + '</section>'; }
function clockHtml() { return '<section class="clock-panel"><div class="greeting">' + esc(state.settings.greeting || ('Hello, ' + state.settings.name)) + '</div><div class="clock" id="liveClock">' + timeText() + '</div><div class="date" id="liveDate">' + today() + '</div></section>'; }
function weatherHtml() { var w=state.weather || {}, hasData=hasWeatherData(w), location=w.locationLabel || state.settings.location || 'Your location', details=hasData ? '<div class="weather-details"><span>H ' + esc(w.high) + '°</span><span>L ' + esc(w.low) + '°</span><span>Rain ' + esc(w.rain) + '%</span></div>' : '', status=w.status==='loading' ? (hasData ? 'Updating weather…' : 'Loading weather…') : w.status==='stale' ? (w.error || 'Saved weather may be outdated') : w.status==='error' ? (w.error || 'Weather unavailable') : (hasData ? updatedLabel(w.timestamp) : 'Weather unavailable'); return card('weather' + (w.status==='loading' ? ' weather-loading' : ''), '<div class="card-label">Weather · ' + esc(location) + '</div><div class="weather-main"><div class="weather-icon">' + esc(hasData ? w.icon : (w.status==='loading' ? '◌' : '—')) + '</div><div><div class="temperature">' + (hasData ? esc(w.temperature) + '°' : '—') + '</div><div class="subtle">' + esc(hasData ? w.condition : (w.status==='loading' ? 'Updating' : 'Weather unavailable')) + '</div></div></div>' + details + '<small>' + esc(status) + '</small>', state.settings.showWeather); }
function taskHtml(t) { return '<div class="task ' + (t.done ? 'done' : '') + '"><button class="check" data-action="toggle-task" data-id="' + t.id + '" aria-label="Toggle task">' + (t.done ? '✓' : '') + '</button><div class="task-details"><div class="task-name">' + esc(t.title) + '</div><div class="task-meta"><span class="priority ' + esc(t.priority) + '">' + esc(t.priority) + '</span>' + (t.due ? '<span>Due ' + esc(t.due) + '</span>' : '') + '</div></div><div class="task-actions"><button class="round-action" data-action="edit-task" data-id="' + t.id + '" aria-label="Edit task">✎</button><button class="round-action" data-action="delete-task" data-id="' + t.id + '" aria-label="Delete task">×</button></div></div>'; }
function todoHtml() { var tasks = state.tasks.slice().sort(function(a,b){return Number(a.done)-Number(b.done);}), count = state.tasks.filter(function(t){return !t.done;}).length; return card('todo-card','<div class="card-title-row"><div><div class="card-label">Your tasks</div><h2>To-Do</h2></div><span class="counter">' + count + ' remaining</span></div><form class="add-task" id="quickTask"><input name="title" aria-label="New task" placeholder="What needs doing?" required maxlength="100"><button class="add-button">+ Add</button></form><div class="task-list">' + (tasks.length ? tasks.map(taskHtml).join('') : '<div class="empty">Nothing here yet. Enjoy the calm.</div>') + '</div>',state.settings.showTodo); }
function schoolHtml() { var next=state.school[0] || {}, classes=state.school.slice(1,4); return card('class-card','<div class="card-title-row"><div class="card-label">Next class</div><button class="edit-link" data-action="edit-school">Edit</button></div><h2>' + esc(next.subject || 'No classes yet') + '</h2><div class="class-time">' + esc(formatClassTime(next.time)) + '</div><div class="subtle">' + esc(next.room || '') + (next.notes ? ' · ' + esc(next.notes) : '') + '</div><div class="card-title-row" style="margin-top:13px"><div class="card-label">Coming up</div><button class="edit-link" data-action="edit-school">Manage</button></div><div class="class-list">' + (classes.length ? classes.map(function(c){return '<div class="class-item"><strong>'+esc(c.subject)+'</strong><br><span class="subtle">'+esc(formatClassTime(c.time))+' · '+esc(c.room)+'</span></div>';}).join('') : '<div class="empty">Add a class to build your schedule.</div>') + '</div>',state.settings.showSchool); }
function formatClassTime(value) { if (!value) return ''; var p=value.split(':'), d=new Date(); d.setHours(+p[0],+p[1]||0); return d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit',hour12:!state.settings.hour24}); }
function movieHtml(){var m=state.movie;return card('movie-card','<div class="card-title-row"><div class="card-label">Currently watching</div><button class="edit-link" data-action="edit-movie">Edit</button></div><div class="poster">◉</div><h2>'+esc(m.title||'Add a movie')+'</h2><div class="rating">★ '+esc(m.rating||'—')+'</div><div class="subtle">'+esc(m.runtime||'')+(m.notes?' · '+esc(m.notes):'')+'</div>',state.settings.showMovie);}
function gamingHtml(){var g=state.gaming, p=Math.max(0,Math.min(100,Number(g.progress)||0));return card('game-card','<div class="card-title-row"><div class="card-label">Current game</div><button class="edit-link" data-action="edit-game">Edit</button></div><h2>'+esc(g.game||'Add a game')+'</h2><div class="subtle">'+esc(g.goal||'Set a goal')+'</div><div class="progress-track"><div class="progress-fill" style="width:'+p+'%"></div></div><div class="subtle" style="margin-top:5px">'+p+'% progress</div>',state.settings.showGaming);}
function home(){return '<div class="page-head"><div><div class="eyebrow">Personal desk dashboard</div><h1>Home</h1></div><button class="secondary-button" data-action="open-task">Add task</button></div>'+clockHtml()+'<div class="dashboard-grid">'+weatherHtml()+todoHtml()+schoolHtml()+movieHtml()+gamingHtml()+'</div>';}
function schoolPage(){return pageForm('School schedule','Keep this simple for now. Your first item is shown as the next class on Home.','school',state.school);}
function moviePage(){return pageForm('Movies','A manual watchlist entry ready for an API later.','movie',state.movie);}
function gamingPage(){return pageForm('Gaming','A small place to keep your current game goal in view.','gaming',state.gaming);}
function pageForm(title,desc,type,data){var fields=''; if(type==='school'){fields='<div class="full"><div class="card-label">Classes</div><div id="classesEditor">'+classEditor(data)+'</div><button class="secondary-button" style="margin-top:12px" data-action="add-class">+ Add class</button></div>';} else if(type==='movie'){fields=field('Movie title','title',data.title)+field('Rating','rating',data.rating,'text','e.g. 8.7')+field('Runtime','runtime',data.runtime)+field('Notes','notes',data.notes,'text','Optional notes',true)+field('Poster URL (optional)','poster',data.poster,'url');} else {fields=field('Game','game',data.game)+field('Progress (%)','progress',data.progress,'number')+field('Current goal','goal',data.goal,'text','What are you working toward?',true);}return '<div class="page-head"><div><div class="eyebrow">DeskOS</div><h1>'+title+'</h1></div></div><section class="card"><p class="subtle">'+desc+'</p><form id="dataForm" data-type="'+type+'"><div class="form-grid">'+fields+'</div><div class="form-actions"><button class="primary-button">Save changes</button></div></form></section>';}
function field(label,name,value,type,placeholder,wide){return '<label class="'+(wide?'full':'')+'">'+label+(wide?'<textarea name="'+name+'" placeholder="'+esc(placeholder||'')+'">'+esc(value)+'</textarea>':'<input name="'+name+'" type="'+(type||'text')+'" value="'+esc(value)+'" placeholder="'+esc(placeholder||'')+'">')+'</label>';}
function classEditor(classes){return classes.map(function(c,i){return '<div class="form-grid class-edit" data-index="'+i+'" style="margin-top:12px"><label>Subject<input name="subject" value="'+esc(c.subject)+'"></label><label>Time<input name="time" type="time" value="'+esc(c.time)+'"></label><label>Room<input name="room" value="'+esc(c.room)+'"></label><label>Notes<input name="notes" value="'+esc(c.notes)+'"></label><button class="secondary-button" data-action="remove-class" data-index="'+i+'" type="button">Remove</button></div>';}).join('') || '<div class="empty">No classes yet.</div>';}
function settings(){var s=state.settings;return '<div class="page-head"><div><div class="eyebrow">Make it yours</div><h1>Settings</h1></div></div><section class="card settings-group"><div class="card-label">Appearance</div>'+selectRow('Theme','theme',s.theme,[['dark','Dark mode'],['light','Light mode'],['auto','Auto theme']])+'<div class="settings-row"><div><strong>Night mode</strong><small>Dim the interface and centre the clock.</small></div><input class="switch" type="checkbox" data-setting="night" '+(s.night?'checked':'')+'></div></section><section class="card settings-group"><div class="card-label">Dashboard cards</div>'+toggleRow('Weather','showWeather',s.showWeather)+toggleRow('To-Do','showTodo',s.showTodo)+toggleRow('School','showSchool',s.showSchool)+toggleRow('Movies','showMovie',s.showMovie)+toggleRow('Gaming','showGaming',s.showGaming)+'</section><section class="card settings-group"><div class="card-label">Clock</div>'+selectRow('Time format','hour24',String(s.hour24),[['false','12-hour'],['true','24-hour']])+'</section><section class="card"><div class="card-label">Personalisation</div><form id="settingsForm" style="margin-top:14px"><div class="form-grid">'+field('Display name','name',s.name)+field('Location','location',s.location)+field('Dashboard greeting','greeting',s.greeting,'text','Optional greeting',true)+'</div><div class="form-actions"><button class="primary-button">Save settings</button></div></form></section>';}
function toggleRow(name,key,on){return '<div class="settings-row"><strong>'+name+'</strong><input class="switch" type="checkbox" data-setting="'+key+'" '+(on?'checked':'')+'></div>';}
function selectRow(name,key,value,options){return '<div class="settings-row"><strong>'+name+'</strong><select data-setting="'+key+'">'+options.map(function(o){return '<option value="'+o[0]+'" '+(String(value)===o[0]?'selected':'')+'>'+o[1]+'</option>';}).join('')+'</select></div>';}
function render(){applyTheme();app.innerHTML=current==='home'?home():current==='school'?schoolPage():current==='movies'?moviePage():current==='gaming'?gamingPage():settings();document.querySelectorAll('.nav-item').forEach(function(b){b.classList.toggle('active',b.dataset.view===current);});}
function applyTheme(){var auto=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches;document.body.classList.toggle('light',state.settings.theme==='light'||(state.settings.theme==='auto'&&auto));document.body.classList.toggle('night',state.settings.night);}
function modal(title,content){backdrop.innerHTML='<section class="modal" role="dialog" aria-modal="true"><h2>'+title+'</h2>'+content+'</section>';backdrop.hidden=false;}
function closeModal(){backdrop.hidden=true;backdrop.innerHTML='';}
function taskModal(task){task=task||{title:'',priority:'medium',due:''};modal(task.id?'Edit task':'New task','<form id="taskForm"><input type="hidden" name="id" value="'+esc(task.id||'')+'"><div class="form-grid">'+field('Task','title',task.title,'text','What needs doing?',true)+selectField('Priority','priority',task.priority)+field('Due date (optional)','due',task.due,'date')+'</div><div class="form-actions"><button type="button" class="secondary-button" data-action="close-modal">Cancel</button><button class="primary-button">Save task</button></div></form>');}
function selectField(label,name,value){return '<label>'+label+'<select name="'+name+'"><option value="high" '+(value==='high'?'selected':'')+'>High</option><option value="medium" '+(value==='medium'?'selected':'')+'>Medium</option><option value="low" '+(value==='low'?'selected':'')+'>Low</option></select></label>';}
document.addEventListener('click',function(e){var nav=e.target.closest('[data-view]');if(nav){current=nav.dataset.view;render();return;}var button=e.target.closest('[data-action]');if(!button)return;var action=button.dataset.action,id=button.dataset.id;if(action==='exit-desk')setDeskMode(false);if(action==='open-task')taskModal();if(action==='toggle-task'){var t=state.tasks.find(function(x){return x.id===id;});t.done=!t.done;save();render();}if(action==='delete-task'){state.tasks=state.tasks.filter(function(x){return x.id!==id;});save();render();}if(action==='edit-task')taskModal(state.tasks.find(function(x){return x.id===id;}));if(action==='close-modal')closeModal();if(action==='edit-school'){current='school';render();}if(action==='edit-movie'){current='movies';render();}if(action==='edit-game'){current='gaming';render();}if(action==='add-class'){state.school.push({id:uid('s'),subject:'New class',time:'09:00',room:'',notes:''});render();}if(action==='remove-class'){state.school.splice(Number(button.dataset.index),1);render();}});
document.addEventListener('submit',function(e){var f=e.target;if(f.id==='quickTask'){e.preventDefault();var title=f.elements.title.value.trim();if(title){state.tasks.unshift({id:uid('t'),title:title,priority:'medium',due:'',done:false});save();render();}}if(f.id==='taskForm'){e.preventDefault();var d=new FormData(f), item={id:d.get('id')||uid('t'),title:d.get('title').trim(),priority:d.get('priority'),due:d.get('due'),done:false};if(!item.title)return;var old=state.tasks.find(function(t){return t.id===item.id;});if(old)item.done=old.done;if(old)Object.assign(old,item);else state.tasks.unshift(item);save();closeModal();render();}if(f.id==='dataForm'){e.preventDefault();var type=f.dataset.type;if(type==='school'){state.school=[].map.call(f.querySelectorAll('.class-edit'),function(row){return {id:uid('s'),subject:row.querySelector('[name=subject]').value, time:row.querySelector('[name=time]').value,room:row.querySelector('[name=room]').value,notes:row.querySelector('[name=notes]').value};});}else {var d=new FormData(f);Object.keys(state[type]).forEach(function(k){if(d.has(k))state[type][k]=d.get(k);});}save();render();}if(f.id==='settingsForm'){e.preventDefault();var d=new FormData(f);['name','location','greeting'].forEach(function(k){state.settings[k]=d.get(k);});save();render();}});
document.addEventListener('change',function(e){var key=e.target.dataset.setting;if(key){state.settings[key]=e.target.type==='checkbox'?e.target.checked:(key==='hour24'?e.target.value==='true':e.target.value);save();render();}});
document.getElementById('nightToggle').addEventListener('click',function(){state.settings.night=!state.settings.night;save();render();});
function setDeskMode(enabled) { document.body.classList.toggle('desk-mode',enabled); document.getElementById('deskToggle').textContent=enabled?'Exit Desk Mode':'Desk Mode'; }
document.getElementById('deskToggle').addEventListener('click',function(){setDeskMode(!document.body.classList.contains('desk-mode'));});
document.getElementById('deskExit').addEventListener('click',function(){setDeskMode(false);});
window.addEventListener('pageshow',function(){if(document.body.classList.contains('desk-mode'))setDeskMode(false);});
setInterval(function(){var c=document.getElementById('liveClock'),d=document.getElementById('liveDate');if(c)c.textContent=timeText();if(d)d.textContent=today();},1000);
/* Explicit tap saves avoid relying on form submission quirks in standalone Safari. */
document.addEventListener('click',function(e){var button=e.target.closest('.primary-button');if(!button)return;var form=button.closest('form');if(!form)return;if(form.id==='dataForm'){e.preventDefault();var type=form.dataset.type;if(type==='school'){state.school=[].map.call(form.querySelectorAll('.class-edit'),function(row){return {id:uid('s'),subject:row.querySelector('[name=subject]').value,time:row.querySelector('[name=time]').value,room:row.querySelector('[name=room]').value,notes:row.querySelector('[name=notes]').value};});}else{var data=new FormData(form);Object.keys(state[type]).forEach(function(key){if(data.has(key))state[type][key]=data.get(key);});}save();render();}if(form.id==='settingsForm'){e.preventDefault();var settingsData=new FormData(form);['name','location','greeting'].forEach(function(key){state.settings[key]=settingsData.get(key);});save();render();}});
/* A settings location change invalidates weather immediately after the save handler completes. */
function refreshForLocationChange(location) { var next=normaliseLocation(location); if (!next || next===normaliseLocation(state.weather && state.weather.location)) return; state.weather={location:next,locationLabel:location,status:'loading'}; save(); renderWeather(); fetchWeather(true); }
document.addEventListener('click',function(e){var button=e.target.closest('.primary-button'),form=button && button.closest('form');if(form && form.id==='settingsForm'){var nextLocation=form.elements.location.value;window.setTimeout(function(){refreshForLocationChange(nextLocation);},0);}},true);
document.addEventListener('submit',function(e){var form=e.target;if(form.id==='settingsForm'){var nextLocation=form.elements.location.value;window.setTimeout(function(){refreshForLocationChange(nextLocation);},0);}},true);
document.addEventListener('visibilitychange',function(){if(!document.hidden && !weatherIsFresh(state.weather)){fetchWeather(false);}});
window.setInterval(function(){if(!weatherIsFresh(state.weather)){fetchWeather(false);}}, WEATHER_TTL);
if (!weatherIsFresh(state.weather)) fetchWeather(false);
render();
}());