Zakkir — مواقيت الصلاة وأذكار
+175
| // Zakkir background — schedules pre-athan reminders and updates the toolbar | ||
| // badge with time-to-next-prayer. Works in Chrome (service_worker) and Firefox | ||
| // (event page via "scripts"). | ||
| const PRAYER_ORDER = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]; | ||
| const DEFAULTS = { | ||
| city: "Cairo", | ||
| country: "Egypt", | ||
| method: 5, | ||
| useCoords: false, | ||
| lat: null, | ||
| lng: null, | ||
| prayerCache: null, | ||
| reminderEnabled: false, | ||
| reminderMinutes: 10, | ||
| athanEnabled: true, | ||
| reminderPrayers: { Fajr: true, Dhuhr: true, Asr: true, Maghrib: true, Isha: true }, | ||
| iqamaEnabled: false, | ||
| iqamaMinutes: 10, | ||
| badgeEnabled: false, | ||
| _sentReminders: {}, | ||
| }; | ||
| function todayKey() { return new Date().toISOString().slice(0, 10); } | ||
| function ddmmyyyy() { | ||
| const d = new Date(), p = (n) => String(n).padStart(2, "0"); | ||
| return `${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()}`; | ||
| } | ||
| function toMin(s) { const [h, m] = s.split(":").map(Number); return h * 60 + m; } | ||
| function getState() { | ||
| return new Promise((res) => chrome.storage.local.get(DEFAULTS, res)); | ||
| } | ||
| async function ensurePrayers(state) { | ||
| const today = todayKey(); | ||
| const c = state.prayerCache; | ||
| const sameLoc = c && (state.useCoords && state.lat != null && state.lng != null | ||
| ? (c.lat === state.lat && c.lng === state.lng) | ||
| : (c.city === state.city && c.country === state.country)); | ||
| if (c && c.date === today && sameLoc && c.method === state.method) return c.timings; | ||
| let url; | ||
| if (state.useCoords && state.lat != null && state.lng != null) { | ||
| url = `https://api.aladhan.com/v1/timings/${ddmmyyyy()}?latitude=${state.lat}&longitude=${state.lng}&method=${state.method || 5}`; | ||
| } else { | ||
| url = `https://api.aladhan.com/v1/timingsByCity/${ddmmyyyy()}?city=${encodeURIComponent(state.city || "Cairo")}&country=${encodeURIComponent(state.country || "Egypt")}&method=${state.method || 5}`; | ||
| } | ||
| try { | ||
| const r = await fetch(url); | ||
| const j = await r.json(); | ||
| if (!j?.data?.timings) throw new Error("bad"); | ||
| const t = j.data.timings; | ||
| const prayers = { Fajr: t.Fajr, Dhuhr: t.Dhuhr, Asr: t.Asr, Maghrib: t.Maghrib, Isha: t.Isha }; | ||
| const h = j.data.date.hijri; | ||
| const hijri = `${h.day} ${h.month.en} ${h.year} AH`; | ||
| const cache = { | ||
| date: today, city: state.city, country: state.country, | ||
| lat: state.lat, lng: state.lng, method: state.method, | ||
| timings: prayers, hijri, | ||
| }; | ||
| await chrome.storage.local.set({ prayerCache: cache }); | ||
| return prayers; | ||
| } catch (e) { | ||
| return c?.timings || null; | ||
| } | ||
| } | ||
| function notify(id, title, message) { | ||
| try { | ||
| chrome.notifications.create(id, { | ||
| type: "basic", | ||
| iconUrl: chrome.runtime.getURL("icon_48.png"), | ||
| title, | ||
| message, | ||
| priority: 2, | ||
| }); | ||
| } catch (e) { /* notifications may be unavailable */ } | ||
| } | ||
| async function tick() { | ||
| const state = await getState(); | ||
| const prayers = await ensurePrayers(state); | ||
| if (!prayers) { | ||
| try { chrome.action.setBadgeText({ text: "" }); } catch {} | ||
| return; | ||
| } | ||
| const now = new Date(); | ||
| const nowM = now.getHours() * 60 + now.getMinutes(); | ||
| const list = PRAYER_ORDER.map((n) => ({ name: n, m: toMin(prayers[n]) })); | ||
| let next = list.find((p) => p.m > nowM); | ||
| if (!next) next = { ...list[0], m: list[0].m + 1440 }; | ||
| const left = next.m - nowM; | ||
| // Badge | ||
| try { | ||
| if (state.badgeEnabled) { | ||
| const h = Math.floor(left / 60); | ||
| const m = left % 60; | ||
| const txt = h > 0 ? `${h}:${String(m).padStart(2, "0")}` : `${m}m`; | ||
| chrome.action.setBadgeText({ text: txt }); | ||
| chrome.action.setBadgeBackgroundColor({ color: "#0ea5e9" }); | ||
| } else { | ||
| chrome.action.setBadgeText({ text: "" }); | ||
| } | ||
| } catch {} | ||
| // Reminders | ||
| const anyReminder = state.reminderEnabled || state.iqamaEnabled || state.athanEnabled; | ||
| if (anyReminder) { | ||
| const preMins = Math.max(0, state.reminderMinutes ?? 10); | ||
| const iqMins = Math.max(0, state.iqamaMinutes ?? 10); | ||
| const enabled = state.reminderPrayers || {}; | ||
| const today = todayKey(); | ||
| const sent = { ...(state._sentReminders || {}) }; | ||
| for (const p of list) { | ||
| if (!enabled[p.name]) continue; | ||
| // Pre-athan reminder | ||
| if (state.reminderEnabled && preMins > 0) { | ||
| const trigger = p.m - preMins; | ||
| const key = `${today}|${p.name}|pre`; | ||
| if (nowM >= trigger && nowM < trigger + 2 && !sent[key]) { | ||
| notify(key, `${p.name} in ${preMins} min`, `Prayer time at ${prayers[p.name]}.`); | ||
| sent[key] = true; | ||
| } | ||
| } | ||
| // At-time notification (athan) | ||
| if (state.athanEnabled) { | ||
| const keyAt = `${today}|${p.name}|at`; | ||
| if (nowM >= p.m && nowM < p.m + 2 && !sent[keyAt]) { | ||
| notify(keyAt, `${p.name} now`, `It's time for ${p.name} (${prayers[p.name]}).`); | ||
| sent[keyAt] = true; | ||
| } | ||
| } | ||
| // Iqama reminder (after athan) | ||
| if (state.iqamaEnabled && iqMins > 0) { | ||
| const iq = p.m + iqMins; | ||
| const keyIq = `${today}|${p.name}|iq`; | ||
| if (nowM >= iq && nowM < iq + 2 && !sent[keyIq]) { | ||
| notify(keyIq, `${p.name} Iqama`, `Iqama time — ${iqMins} min after athan.`); | ||
| sent[keyIq] = true; | ||
| } | ||
| } | ||
| } | ||
| // Keep only today's entries | ||
| const cleaned = {}; | ||
| for (const k of Object.keys(sent)) if (k.startsWith(today + "|")) cleaned[k] = sent[k]; | ||
| await chrome.storage.local.set({ _sentReminders: cleaned }); | ||
| } | ||
| } | ||
| function ensureAlarm() { | ||
| try { | ||
| chrome.alarms.get("tick", (a) => { | ||
| if (!a) chrome.alarms.create("tick", { periodInMinutes: 1 }); | ||
| }); | ||
| } catch { | ||
| try { chrome.alarms.create("tick", { periodInMinutes: 1 }); } catch {} | ||
| } | ||
| } | ||
| chrome.runtime.onInstalled.addListener(() => { ensureAlarm(); tick(); }); | ||
| chrome.runtime.onStartup?.addListener(() => { ensureAlarm(); tick(); }); | ||
| chrome.alarms.onAlarm.addListener((a) => { if (a.name === "tick") tick(); }); | ||
| chrome.runtime.onMessage.addListener((msg) => { | ||
| if (msg && msg.type === "refresh") { ensureAlarm(); tick(); } | ||
| }); | ||
| // Run once on script load (covers SW wake-ups) | ||
| ensureAlarm(); | ||
| tick(); |
+25
| html, body { margin: 0; height: 100%; font-family: system-ui, -apple-system, sans-serif; background: #0f1115; color: #e6e8ee; } | ||
| #topbar { | ||
| position: fixed; top: 0; left: 0; right: 0; z-index: 1000; | ||
| background: rgba(15, 17, 21, 0.96); | ||
| border-bottom: 1px solid #262b38; | ||
| padding: 10px 14px; | ||
| display: flex; align-items: center; justify-content: space-between; gap: 12px; | ||
| flex-wrap: wrap; | ||
| } | ||
| #info { font-size: 13px; color: #cfd3dc; } | ||
| .actions { display: flex; gap: 6px; align-items: center; } | ||
| #search { | ||
| background: #161922; color: #e6e8ee; border: 1px solid #262b38; | ||
| border-radius: 8px; padding: 7px 10px; font-size: 13px; width: 220px; | ||
| } | ||
| #search:focus { outline: none; border-color: #60a5fa; } | ||
| button { | ||
| background: #1d2230; color: #e6e8ee; border: 1px solid #262b38; | ||
| border-radius: 8px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer; | ||
| } | ||
| button:hover { border-color: #60a5fa; } | ||
| #useBtn { background: #60a5fa; color: #0b0f1a; border-color: #60a5fa; } | ||
| #useBtn:hover { filter: brightness(1.05); } | ||
| #map { position: absolute; top: 60px; bottom: 0; left: 0; right: 0; } | ||
| @media (max-width: 600px) { #search { width: 140px; } #map { top: 96px; } } |
+22
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>Zakkir — Pick location</title> | ||
| <link rel="stylesheet" href="vendor/leaflet.css" /> | ||
| <link rel="stylesheet" href="map.css" /> | ||
| </head> | ||
| <body> | ||
| <div id="topbar"> | ||
| <div id="info">Click the map to pick your location.</div> | ||
| <div class="actions"> | ||
| <input id="search" type="text" placeholder="Search city…" /> | ||
| <button id="searchBtn">Search</button> | ||
| <button id="useBtn">Use this location</button> | ||
| </div> | ||
| </div> | ||
| <div id="map"></div> | ||
| <script src="vendor/leaflet.js"></script> | ||
| <script src="map.js"></script> | ||
| </body> | ||
| </html> |
+103
| // Zakkir map picker — bundled Leaflet + OSM tiles. Click to drop a pin, then | ||
| // "Use this location" writes lat/lng + locationName back to chrome.storage and | ||
| // tells the background to refresh prayer times. | ||
| const api = globalThis.chrome || globalThis.browser; | ||
| const base = api?.runtime?.getURL ? api.runtime.getURL("vendor/") : "vendor/"; | ||
| // Point Leaflet's default icon at our bundled images. | ||
| L.Icon.Default.mergeOptions({ | ||
| iconUrl: base + "marker-icon.png", | ||
| iconRetinaUrl: base + "marker-icon-2x.png", | ||
| shadowUrl: base + "marker-shadow.png", | ||
| }); | ||
| const url = new URL(location.href); | ||
| const startLat = parseFloat(url.searchParams.get("lat")); | ||
| const startLng = parseFloat(url.searchParams.get("lng")); | ||
| const hasStart = Number.isFinite(startLat) && Number.isFinite(startLng); | ||
| const initLat = hasStart ? startLat : 30.0444; | ||
| const initLng = hasStart ? startLng : 31.2357; | ||
| const map = L.map("map").setView([initLat, initLng], hasStart ? 10 : 4); | ||
| // CARTO Voyager tiles — permissive on referrer (OSM volunteer servers block | ||
| // extension origins with a 403 "Access blocked" tile). | ||
| L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", { | ||
| attribution: '© OpenStreetMap contributors © CARTO', | ||
| subdomains: "abcd", | ||
| maxZoom: 19, | ||
| }).addTo(map); | ||
| let picked = { lat: initLat, lng: initLng }; | ||
| let marker = L.marker([initLat, initLng], { draggable: true }).addTo(map); | ||
| const info = document.getElementById("info"); | ||
| const useBtn = document.getElementById("useBtn"); | ||
| const search = document.getElementById("search"); | ||
| const searchBtn = document.getElementById("searchBtn"); | ||
| function setPick(lat, lng, fly = false) { | ||
| picked = { lat, lng }; | ||
| marker.setLatLng([lat, lng]); | ||
| if (fly) map.setView([lat, lng], Math.max(map.getZoom(), 10)); | ||
| info.textContent = `Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; | ||
| } | ||
| map.on("click", (e) => setPick(e.latlng.lat, e.latlng.lng)); | ||
| marker.on("dragend", () => { | ||
| const ll = marker.getLatLng(); | ||
| setPick(ll.lat, ll.lng); | ||
| }); | ||
| async function doSearch() { | ||
| const q = search.value.trim(); | ||
| if (!q) return; | ||
| searchBtn.disabled = true; | ||
| searchBtn.textContent = "…"; | ||
| try { | ||
| const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`); | ||
| const j = await r.json(); | ||
| if (j[0]) { | ||
| setPick(parseFloat(j[0].lat), parseFloat(j[0].lon), true); | ||
| } else { | ||
| info.textContent = "No matches — try a different search."; | ||
| } | ||
| } catch { | ||
| info.textContent = "Search failed. Check your connection."; | ||
| } finally { | ||
| searchBtn.disabled = false; | ||
| searchBtn.textContent = "Search"; | ||
| } | ||
| } | ||
| searchBtn.addEventListener("click", doSearch); | ||
| search.addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); }); | ||
| useBtn.addEventListener("click", async () => { | ||
| useBtn.disabled = true; | ||
| useBtn.textContent = "Saving…"; | ||
| let name = ""; | ||
| try { | ||
| const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${picked.lat}&lon=${picked.lng}&zoom=10`); | ||
| const j = await r.json(); | ||
| const a = j.address || {}; | ||
| name = a.city || a.town || a.village || a.county || a.state || (j.display_name || "").split(",")[0] || ""; | ||
| } catch { /* fine — fall back to coords */ } | ||
| await new Promise((res) => api.storage.local.set({ | ||
| lat: picked.lat, | ||
| lng: picked.lng, | ||
| useCoords: true, | ||
| locationName: name, | ||
| locationSource: "map", | ||
| locationTab: "map", | ||
| prayerCache: null, | ||
| }, res)); | ||
| try { api.runtime.sendMessage({ type: "refresh" }); } catch {} | ||
| window.close(); | ||
| }); | ||
| // Initial label | ||
| info.textContent = hasStart | ||
| ? `Selected: ${initLat.toFixed(4)}, ${initLng.toFixed(4)} — drag the pin or click anywhere.` | ||
| : `Click anywhere on the map to pick your location.`; |
| /* required styles */ | ||
| .leaflet-pane, | ||
| .leaflet-tile, | ||
| .leaflet-marker-icon, | ||
| .leaflet-marker-shadow, | ||
| .leaflet-tile-container, | ||
| .leaflet-pane > svg, | ||
| .leaflet-pane > canvas, | ||
| .leaflet-zoom-box, | ||
| .leaflet-image-layer, | ||
| .leaflet-layer { | ||
| position: absolute; | ||
| left: 0; | ||
| top: 0; | ||
| } | ||
| .leaflet-container { | ||
| overflow: hidden; | ||
| } | ||
| .leaflet-tile, | ||
| .leaflet-marker-icon, | ||
| .leaflet-marker-shadow { | ||
| -webkit-user-select: none; | ||
| -moz-user-select: none; | ||
| user-select: none; | ||
| -webkit-user-drag: none; | ||
| } | ||
| /* Prevents IE11 from highlighting tiles in blue */ | ||
| .leaflet-tile::selection { | ||
| background: transparent; | ||
| } | ||
| /* Safari renders non-retina tile on retina better with this, but Chrome is worse */ | ||
| .leaflet-safari .leaflet-tile { | ||
| image-rendering: -webkit-optimize-contrast; | ||
| } | ||
| /* hack that prevents hw layers "stretching" when loading new tiles */ | ||
| .leaflet-safari .leaflet-tile-container { | ||
| width: 1600px; | ||
| height: 1600px; | ||
| -webkit-transform-origin: 0 0; | ||
| } | ||
| .leaflet-marker-icon, | ||
| .leaflet-marker-shadow { | ||
| display: block; | ||
| } | ||
| /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ | ||
| /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ | ||
| .leaflet-container .leaflet-overlay-pane svg { | ||
| max-width: none !important; | ||
| max-height: none !important; | ||
| } | ||
| .leaflet-container .leaflet-marker-pane img, | ||
| .leaflet-container .leaflet-shadow-pane img, | ||
| .leaflet-container .leaflet-tile-pane img, | ||
| .leaflet-container img.leaflet-image-layer, | ||
| .leaflet-container .leaflet-tile { | ||
| max-width: none !important; | ||
| max-height: none !important; | ||
| width: auto; | ||
| padding: 0; | ||
| } | ||
| .leaflet-container img.leaflet-tile { | ||
| /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ | ||
| mix-blend-mode: plus-lighter; | ||
| } | ||
| .leaflet-container.leaflet-touch-zoom { | ||
| -ms-touch-action: pan-x pan-y; | ||
| touch-action: pan-x pan-y; | ||
| } | ||
| .leaflet-container.leaflet-touch-drag { | ||
| -ms-touch-action: pinch-zoom; | ||
| /* Fallback for FF which doesn't support pinch-zoom */ | ||
| touch-action: none; | ||
| touch-action: pinch-zoom; | ||
| } | ||
| .leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { | ||
| -ms-touch-action: none; | ||
| touch-action: none; | ||
| } | ||
| .leaflet-container { | ||
| -webkit-tap-highlight-color: transparent; | ||
| } | ||
| .leaflet-container a { | ||
| -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); | ||
| } | ||
| .leaflet-tile { | ||
| filter: inherit; | ||
| visibility: hidden; | ||
| } | ||
| .leaflet-tile-loaded { | ||
| visibility: inherit; | ||
| } | ||
| .leaflet-zoom-box { | ||
| width: 0; | ||
| height: 0; | ||
| -moz-box-sizing: border-box; | ||
| box-sizing: border-box; | ||
| z-index: 800; | ||
| } | ||
| /* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ | ||
| .leaflet-overlay-pane svg { | ||
| -moz-user-select: none; | ||
| } | ||
| .leaflet-pane { z-index: 400; } | ||
| .leaflet-tile-pane { z-index: 200; } | ||
| .leaflet-overlay-pane { z-index: 400; } | ||
| .leaflet-shadow-pane { z-index: 500; } | ||
| .leaflet-marker-pane { z-index: 600; } | ||
| .leaflet-tooltip-pane { z-index: 650; } | ||
| .leaflet-popup-pane { z-index: 700; } | ||
| .leaflet-map-pane canvas { z-index: 100; } | ||
| .leaflet-map-pane svg { z-index: 200; } | ||
| .leaflet-vml-shape { | ||
| width: 1px; | ||
| height: 1px; | ||
| } | ||
| .lvml { | ||
| behavior: url(#default#VML); | ||
| display: inline-block; | ||
| position: absolute; | ||
| } | ||
| /* control positioning */ | ||
| .leaflet-control { | ||
| position: relative; | ||
| z-index: 800; | ||
| pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ | ||
| pointer-events: auto; | ||
| } | ||
| .leaflet-top, | ||
| .leaflet-bottom { | ||
| position: absolute; | ||
| z-index: 1000; | ||
| pointer-events: none; | ||
| } | ||
| .leaflet-top { | ||
| top: 0; | ||
| } | ||
| .leaflet-right { | ||
| right: 0; | ||
| } | ||
| .leaflet-bottom { | ||
| bottom: 0; | ||
| } | ||
| .leaflet-left { | ||
| left: 0; | ||
| } | ||
| .leaflet-control { | ||
| float: left; | ||
| clear: both; | ||
| } | ||
| .leaflet-right .leaflet-control { | ||
| float: right; | ||
| } | ||
| .leaflet-top .leaflet-control { | ||
| margin-top: 10px; | ||
| } | ||
| .leaflet-bottom .leaflet-control { | ||
| margin-bottom: 10px; | ||
| } | ||
| .leaflet-left .leaflet-control { | ||
| margin-left: 10px; | ||
| } | ||
| .leaflet-right .leaflet-control { | ||
| margin-right: 10px; | ||
| } | ||
| /* zoom and fade animations */ | ||
| .leaflet-fade-anim .leaflet-popup { | ||
| opacity: 0; | ||
| -webkit-transition: opacity 0.2s linear; | ||
| -moz-transition: opacity 0.2s linear; | ||
| transition: opacity 0.2s linear; | ||
| } | ||
| .leaflet-fade-anim .leaflet-map-pane .leaflet-popup { | ||
| opacity: 1; | ||
| } | ||
| .leaflet-zoom-animated { | ||
| -webkit-transform-origin: 0 0; | ||
| -ms-transform-origin: 0 0; | ||
| transform-origin: 0 0; | ||
| } | ||
| svg.leaflet-zoom-animated { | ||
| will-change: transform; | ||
| } | ||
| .leaflet-zoom-anim .leaflet-zoom-animated { | ||
| -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); | ||
| -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); | ||
| transition: transform 0.25s cubic-bezier(0,0,0.25,1); | ||
| } | ||
| .leaflet-zoom-anim .leaflet-tile, | ||
| .leaflet-pan-anim .leaflet-tile { | ||
| -webkit-transition: none; | ||
| -moz-transition: none; | ||
| transition: none; | ||
| } | ||
| .leaflet-zoom-anim .leaflet-zoom-hide { | ||
| visibility: hidden; | ||
| } | ||
| /* cursors */ | ||
| .leaflet-interactive { | ||
| cursor: pointer; | ||
| } | ||
| .leaflet-grab { | ||
| cursor: -webkit-grab; | ||
| cursor: -moz-grab; | ||
| cursor: grab; | ||
| } | ||
| .leaflet-crosshair, | ||
| .leaflet-crosshair .leaflet-interactive { | ||
| cursor: crosshair; | ||
| } | ||
| .leaflet-popup-pane, | ||
| .leaflet-control { | ||
| cursor: auto; | ||
| } | ||
| .leaflet-dragging .leaflet-grab, | ||
| .leaflet-dragging .leaflet-grab .leaflet-interactive, | ||
| .leaflet-dragging .leaflet-marker-draggable { | ||
| cursor: move; | ||
| cursor: -webkit-grabbing; | ||
| cursor: -moz-grabbing; | ||
| cursor: grabbing; | ||
| } | ||
| /* marker & overlays interactivity */ | ||
| .leaflet-marker-icon, | ||
| .leaflet-marker-shadow, | ||
| .leaflet-image-layer, | ||
| .leaflet-pane > svg path, | ||
| .leaflet-tile-container { | ||
| pointer-events: none; | ||
| } | ||
| .leaflet-marker-icon.leaflet-interactive, | ||
| .leaflet-image-layer.leaflet-interactive, | ||
| .leaflet-pane > svg path.leaflet-interactive, | ||
| svg.leaflet-image-layer.leaflet-interactive path { | ||
| pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ | ||
| pointer-events: auto; | ||
| } | ||
| /* visual tweaks */ | ||
| .leaflet-container { | ||
| background: #ddd; | ||
| outline-offset: 1px; | ||
| } | ||
| .leaflet-container a { | ||
| color: #0078A8; | ||
| } | ||
| .leaflet-zoom-box { | ||
| border: 2px dotted #38f; | ||
| background: rgba(255,255,255,0.5); | ||
| } | ||
| /* general typography */ | ||
| .leaflet-container { | ||
| font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; | ||
| font-size: 12px; | ||
| font-size: 0.75rem; | ||
| line-height: 1.5; | ||
| } | ||
| /* general toolbar styles */ | ||
| .leaflet-bar { | ||
| box-shadow: 0 1px 5px rgba(0,0,0,0.65); | ||
| border-radius: 4px; | ||
| } | ||
| .leaflet-bar a { | ||
| background-color: #fff; | ||
| border-bottom: 1px solid #ccc; | ||
| width: 26px; | ||
| height: 26px; | ||
| line-height: 26px; | ||
| display: block; | ||
| text-align: center; | ||
| text-decoration: none; | ||
| color: black; | ||
| } | ||
| .leaflet-bar a, | ||
| .leaflet-control-layers-toggle { | ||
| background-position: 50% 50%; | ||
| background-repeat: no-repeat; | ||
| display: block; | ||
| } | ||
| .leaflet-bar a:hover, | ||
| .leaflet-bar a:focus { | ||
| background-color: #f4f4f4; | ||
| } | ||
| .leaflet-bar a:first-child { | ||
| border-top-left-radius: 4px; | ||
| border-top-right-radius: 4px; | ||
| } | ||
| .leaflet-bar a:last-child { | ||
| border-bottom-left-radius: 4px; | ||
| border-bottom-right-radius: 4px; | ||
| border-bottom: none; | ||
| } | ||
| .leaflet-bar a.leaflet-disabled { | ||
| cursor: default; | ||
| background-color: #f4f4f4; | ||
| color: #bbb; | ||
| } | ||
| .leaflet-touch .leaflet-bar a { | ||
| width: 30px; | ||
| height: 30px; | ||
| line-height: 30px; | ||
| } | ||
| .leaflet-touch .leaflet-bar a:first-child { | ||
| border-top-left-radius: 2px; | ||
| border-top-right-radius: 2px; | ||
| } | ||
| .leaflet-touch .leaflet-bar a:last-child { | ||
| border-bottom-left-radius: 2px; | ||
| border-bottom-right-radius: 2px; | ||
| } | ||
| /* zoom control */ | ||
| .leaflet-control-zoom-in, | ||
| .leaflet-control-zoom-out { | ||
| font: bold 18px 'Lucida Console', Monaco, monospace; | ||
| text-indent: 1px; | ||
| } | ||
| .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { | ||
| font-size: 22px; | ||
| } | ||
| /* layers control */ | ||
| .leaflet-control-layers { | ||
| box-shadow: 0 1px 5px rgba(0,0,0,0.4); | ||
| background: #fff; | ||
| border-radius: 5px; | ||
| } | ||
| .leaflet-control-layers-toggle { | ||
| background-image: url(images/layers.png); | ||
| width: 36px; | ||
| height: 36px; | ||
| } | ||
| .leaflet-retina .leaflet-control-layers-toggle { | ||
| background-image: url(images/layers-2x.png); | ||
| background-size: 26px 26px; | ||
| } | ||
| .leaflet-touch .leaflet-control-layers-toggle { | ||
| width: 44px; | ||
| height: 44px; | ||
| } | ||
| .leaflet-control-layers .leaflet-control-layers-list, | ||
| .leaflet-control-layers-expanded .leaflet-control-layers-toggle { | ||
| display: none; | ||
| } | ||
| .leaflet-control-layers-expanded .leaflet-control-layers-list { | ||
| display: block; | ||
| position: relative; | ||
| } | ||
| .leaflet-control-layers-expanded { | ||
| padding: 6px 10px 6px 6px; | ||
| color: #333; | ||
| background: #fff; | ||
| } | ||
| .leaflet-control-layers-scrollbar { | ||
| overflow-y: scroll; | ||
| overflow-x: hidden; | ||
| padding-right: 5px; | ||
| } | ||
| .leaflet-control-layers-selector { | ||
| margin-top: 2px; | ||
| position: relative; | ||
| top: 1px; | ||
| } | ||
| .leaflet-control-layers label { | ||
| display: block; | ||
| font-size: 13px; | ||
| font-size: 1.08333em; | ||
| } | ||
| .leaflet-control-layers-separator { | ||
| height: 0; | ||
| border-top: 1px solid #ddd; | ||
| margin: 5px -10px 5px -6px; | ||
| } | ||
| /* Default icon URLs */ | ||
| .leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ | ||
| background-image: url(images/marker-icon.png); | ||
| } | ||
| /* attribution and scale controls */ | ||
| .leaflet-container .leaflet-control-attribution { | ||
| background: #fff; | ||
| background: rgba(255, 255, 255, 0.8); | ||
| margin: 0; | ||
| } | ||
| .leaflet-control-attribution, | ||
| .leaflet-control-scale-line { | ||
| padding: 0 5px; | ||
| color: #333; | ||
| line-height: 1.4; | ||
| } | ||
| .leaflet-control-attribution a { | ||
| text-decoration: none; | ||
| } | ||
| .leaflet-control-attribution a:hover, | ||
| .leaflet-control-attribution a:focus { | ||
| text-decoration: underline; | ||
| } | ||
| .leaflet-attribution-flag { | ||
| display: inline !important; | ||
| vertical-align: baseline !important; | ||
| width: 1em; | ||
| height: 0.6669em; | ||
| } | ||
| .leaflet-left .leaflet-control-scale { | ||
| margin-left: 5px; | ||
| } | ||
| .leaflet-bottom .leaflet-control-scale { | ||
| margin-bottom: 5px; | ||
| } | ||
| .leaflet-control-scale-line { | ||
| border: 2px solid #777; | ||
| border-top: none; | ||
| line-height: 1.1; | ||
| padding: 2px 5px 1px; | ||
| white-space: nowrap; | ||
| -moz-box-sizing: border-box; | ||
| box-sizing: border-box; | ||
| background: rgba(255, 255, 255, 0.8); | ||
| text-shadow: 1px 1px #fff; | ||
| } | ||
| .leaflet-control-scale-line:not(:first-child) { | ||
| border-top: 2px solid #777; | ||
| border-bottom: none; | ||
| margin-top: -2px; | ||
| } | ||
| .leaflet-control-scale-line:not(:first-child):not(:last-child) { | ||
| border-bottom: 2px solid #777; | ||
| } | ||
| .leaflet-touch .leaflet-control-attribution, | ||
| .leaflet-touch .leaflet-control-layers, | ||
| .leaflet-touch .leaflet-bar { | ||
| box-shadow: none; | ||
| } | ||
| .leaflet-touch .leaflet-control-layers, | ||
| .leaflet-touch .leaflet-bar { | ||
| border: 2px solid rgba(0,0,0,0.2); | ||
| background-clip: padding-box; | ||
| } | ||
| /* popup */ | ||
| .leaflet-popup { | ||
| position: absolute; | ||
| text-align: center; | ||
| margin-bottom: 20px; | ||
| } | ||
| .leaflet-popup-content-wrapper { | ||
| padding: 1px; | ||
| text-align: left; | ||
| border-radius: 12px; | ||
| } | ||
| .leaflet-popup-content { | ||
| margin: 13px 24px 13px 20px; | ||
| line-height: 1.3; | ||
| font-size: 13px; | ||
| font-size: 1.08333em; | ||
| min-height: 1px; | ||
| } | ||
| .leaflet-popup-content p { | ||
| margin: 17px 0; | ||
| margin: 1.3em 0; | ||
| } | ||
| .leaflet-popup-tip-container { | ||
| width: 40px; | ||
| height: 20px; | ||
| position: absolute; | ||
| left: 50%; | ||
| margin-top: -1px; | ||
| margin-left: -20px; | ||
| overflow: hidden; | ||
| pointer-events: none; | ||
| } | ||
| .leaflet-popup-tip { | ||
| width: 17px; | ||
| height: 17px; | ||
| padding: 1px; | ||
| margin: -10px auto 0; | ||
| pointer-events: auto; | ||
| -webkit-transform: rotate(45deg); | ||
| -moz-transform: rotate(45deg); | ||
| -ms-transform: rotate(45deg); | ||
| transform: rotate(45deg); | ||
| } | ||
| .leaflet-popup-content-wrapper, | ||
| .leaflet-popup-tip { | ||
| background: white; | ||
| color: #333; | ||
| box-shadow: 0 3px 14px rgba(0,0,0,0.4); | ||
| } | ||
| .leaflet-container a.leaflet-popup-close-button { | ||
| position: absolute; | ||
| top: 0; | ||
| right: 0; | ||
| border: none; | ||
| text-align: center; | ||
| width: 24px; | ||
| height: 24px; | ||
| font: 16px/24px Tahoma, Verdana, sans-serif; | ||
| color: #757575; | ||
| text-decoration: none; | ||
| background: transparent; | ||
| } | ||
| .leaflet-container a.leaflet-popup-close-button:hover, | ||
| .leaflet-container a.leaflet-popup-close-button:focus { | ||
| color: #585858; | ||
| } | ||
| .leaflet-popup-scrolled { | ||
| overflow: auto; | ||
| } | ||
| .leaflet-oldie .leaflet-popup-content-wrapper { | ||
| -ms-zoom: 1; | ||
| } | ||
| .leaflet-oldie .leaflet-popup-tip { | ||
| width: 24px; | ||
| margin: 0 auto; | ||
| -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; | ||
| filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); | ||
| } | ||
| .leaflet-oldie .leaflet-control-zoom, | ||
| .leaflet-oldie .leaflet-control-layers, | ||
| .leaflet-oldie .leaflet-popup-content-wrapper, | ||
| .leaflet-oldie .leaflet-popup-tip { | ||
| border: 1px solid #999; | ||
| } | ||
| /* div icon */ | ||
| .leaflet-div-icon { | ||
| background: #fff; | ||
| border: 1px solid #666; | ||
| } | ||
| /* Tooltip */ | ||
| /* Base styles for the element that has a tooltip */ | ||
| .leaflet-tooltip { | ||
| position: absolute; | ||
| padding: 6px; | ||
| background-color: #fff; | ||
| border: 1px solid #fff; | ||
| border-radius: 3px; | ||
| color: #222; | ||
| white-space: nowrap; | ||
| -webkit-user-select: none; | ||
| -moz-user-select: none; | ||
| -ms-user-select: none; | ||
| user-select: none; | ||
| pointer-events: none; | ||
| box-shadow: 0 1px 3px rgba(0,0,0,0.4); | ||
| } | ||
| .leaflet-tooltip.leaflet-interactive { | ||
| cursor: pointer; | ||
| pointer-events: auto; | ||
| } | ||
| .leaflet-tooltip-top:before, | ||
| .leaflet-tooltip-bottom:before, | ||
| .leaflet-tooltip-left:before, | ||
| .leaflet-tooltip-right:before { | ||
| position: absolute; | ||
| pointer-events: none; | ||
| border: 6px solid transparent; | ||
| background: transparent; | ||
| content: ""; | ||
| } | ||
| /* Directions */ | ||
| .leaflet-tooltip-bottom { | ||
| margin-top: 6px; | ||
| } | ||
| .leaflet-tooltip-top { | ||
| margin-top: -6px; | ||
| } | ||
| .leaflet-tooltip-bottom:before, | ||
| .leaflet-tooltip-top:before { | ||
| left: 50%; | ||
| margin-left: -6px; | ||
| } | ||
| .leaflet-tooltip-top:before { | ||
| bottom: 0; | ||
| margin-bottom: -12px; | ||
| border-top-color: #fff; | ||
| } | ||
| .leaflet-tooltip-bottom:before { | ||
| top: 0; | ||
| margin-top: -12px; | ||
| margin-left: -6px; | ||
| border-bottom-color: #fff; | ||
| } | ||
| .leaflet-tooltip-left { | ||
| margin-left: -6px; | ||
| } | ||
| .leaflet-tooltip-right { | ||
| margin-left: 6px; | ||
| } | ||
| .leaflet-tooltip-left:before, | ||
| .leaflet-tooltip-right:before { | ||
| top: 50%; | ||
| margin-top: -6px; | ||
| } | ||
| .leaflet-tooltip-left:before { | ||
| right: 0; | ||
| margin-right: -12px; | ||
| border-left-color: #fff; | ||
| } | ||
| .leaflet-tooltip-right:before { | ||
| left: 0; | ||
| margin-left: -12px; | ||
| border-right-color: #fff; | ||
| } | ||
| /* Printing */ | ||
| @media print { | ||
| /* Prevent printers from removing background-images of controls. */ | ||
| .leaflet-control { | ||
| -webkit-print-color-adjust: exact; | ||
| print-color-adjust: exact; | ||
| } | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
+23
-5
| { | ||
| "manifest_version": 3, | ||
| "name": "Zakkir", | ||
| "version": "1.1.0", | ||
| "version": "1.3.8", | ||
| "description": "Prayer times + Azkar (Hisn al-Muslim) in your toolbar.", | ||
| "permissions": [ | ||
| "storage" | ||
| "storage", | ||
| "alarms", | ||
| "notifications", | ||
| "geolocation" | ||
| ], | ||
| "host_permissions": [ | ||
| "https://api.aladhan.com/*" | ||
| "https://api.aladhan.com/*", | ||
| "https://*.basemaps.cartocdn.com/*", | ||
| "https://*.tile.openstreetmap.org/*", | ||
| "https://nominatim.openstreetmap.org/*" | ||
| ], | ||
@@ -25,6 +31,11 @@ "action": { | ||
| }, | ||
| "background": { | ||
| "scripts": [ | ||
| "background.js" | ||
| ] | ||
| }, | ||
| "browser_specific_settings": { | ||
| "gecko": { | ||
| "id": "zakkir@lovable.app", | ||
| "strict_min_version": "140.0", | ||
| "strict_min_version": "142.0", | ||
| "data_collection_permissions": { | ||
@@ -41,3 +52,10 @@ "required": [ | ||
| "popup.html", | ||
| "azkar.json" | ||
| "azkar.json", | ||
| "map.html", | ||
| "vendor/leaflet.js", | ||
| "vendor/leaflet.css", | ||
| "vendor/marker-icon.png", | ||
| "vendor/marker-icon-2x.png", | ||
| "vendor/marker-shadow.png", | ||
| "icon_48.png" | ||
| ], | ||
@@ -44,0 +62,0 @@ "matches": [ |
+305
-6
@@ -200,12 +200,21 @@ /* ===== MINIMAL THEMES (no gradients) ===== */ | ||
| border-radius: 10px; | ||
| transition: background 0.2s; | ||
| border: 1px solid transparent; | ||
| background: transparent; | ||
| color: inherit; | ||
| cursor: pointer; | ||
| font-family: inherit; | ||
| transition: background 0.18s, border-color 0.18s, transform 0.1s; | ||
| } | ||
| .prayer:hover { background: var(--surface-2); } | ||
| .prayer:active { transform: scale(0.97); } | ||
| .prayer .n { font-size: 0.62em; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; } | ||
| .prayer .t { font-size: 0.85em; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; } | ||
| .prayer.active { | ||
| .prayer.current { | ||
| background: var(--accent); | ||
| color: var(--accent-ink); | ||
| border-color: var(--accent); | ||
| } | ||
| .prayer.active .n, | ||
| .prayer.active .t { color: var(--accent-ink); } | ||
| .prayer.current .n, | ||
| .prayer.current .t { color: var(--accent-ink); } | ||
| .prayer.focused { border-color: var(--accent); } | ||
| .prayer.current.focused { box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 40%, transparent); } | ||
@@ -350,3 +359,11 @@ /* AZKAR */ | ||
| .input:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 18%, transparent); } | ||
| input[type="range"] { max-width: 50%; accent-color: var(--accent); } | ||
| input[type="range"] { max-width: 50%; accent-color: var(--accent); flex: 1; } | ||
| .row > span { | ||
| font-variant-numeric: tabular-nums; | ||
| min-width: 3.5em; | ||
| text-align: right; | ||
| display: inline-block; | ||
| font-size: 0.85em; | ||
| color: var(--muted); | ||
| } | ||
@@ -635,1 +652,283 @@ .font-grid { | ||
| } | ||
| /* ===== NEW (v1.2): location, reminders, focused-prayer countdown ===== */ | ||
| .loc-status { | ||
| font-size: 0.85em; | ||
| color: var(--muted); | ||
| padding: 6px 0 0; | ||
| } | ||
| .loc-status b { color: var(--ink); } | ||
| .loc-actions { | ||
| display: grid; | ||
| grid-template-columns: 1fr 1fr; | ||
| gap: 6px; | ||
| padding: 8px 0 4px; | ||
| } | ||
| .loc-btn { | ||
| background: var(--surface); | ||
| border: 1px solid var(--line); | ||
| color: var(--ink); | ||
| border-radius: 10px; | ||
| padding: 10px 8px; | ||
| font-size: 0.85em; | ||
| font-family: inherit; | ||
| cursor: pointer; | ||
| transition: border-color 0.18s, background 0.18s, transform 0.12s; | ||
| } | ||
| .loc-btn:hover { border-color: var(--accent); } | ||
| .loc-btn:active { transform: scale(0.98); } | ||
| .loc-btn.primary { | ||
| background: var(--accent); | ||
| color: var(--accent-ink); | ||
| border-color: var(--accent); | ||
| } | ||
| /* iOS-style switch */ | ||
| .switch { position: relative; display: inline-block; width: 38px; height: 22px; } | ||
| .switch input { opacity: 0; width: 0; height: 0; } | ||
| .switch span { | ||
| position: absolute; inset: 0; cursor: pointer; | ||
| background: var(--line); | ||
| border-radius: 999px; | ||
| transition: background 0.2s; | ||
| } | ||
| .switch span::before { | ||
| content: ""; position: absolute; | ||
| width: 16px; height: 16px; left: 3px; top: 3px; | ||
| background: var(--surface); | ||
| border-radius: 50%; | ||
| transition: transform 0.2s; | ||
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); | ||
| } | ||
| .switch input:checked + span { background: var(--accent); } | ||
| .switch input:checked + span::before { transform: translateX(16px); } | ||
| /* Per-prayer reminder toggles */ | ||
| .prayer-toggles { | ||
| display: grid; | ||
| grid-template-columns: repeat(5, 1fr); | ||
| gap: 4px; | ||
| padding: 4px 0 8px; | ||
| } | ||
| .pt { | ||
| display: flex; flex-direction: column; align-items: center; gap: 4px; | ||
| background: var(--surface); | ||
| border: 1px solid var(--line); | ||
| border-radius: 10px; | ||
| padding: 8px 4px; | ||
| font-size: 0.7em; | ||
| text-align: center; | ||
| cursor: pointer; | ||
| letter-spacing: 0.04em; | ||
| transition: border-color 0.18s, background 0.18s; | ||
| } | ||
| .pt input { accent-color: var(--accent); } | ||
| .pt.on { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 12%, transparent); } | ||
| /* Focused-prayer countdown line */ | ||
| .focus-line { | ||
| display: flex; align-items: center; justify-content: space-between; gap: 8px; | ||
| background: transparent; | ||
| border: 0; | ||
| border-radius: 0; | ||
| padding: 0; | ||
| margin: 14px 2px 2px; | ||
| font-size: 0.8em; | ||
| color: var(--muted); | ||
| animation: fadeIn 0.2s ease; | ||
| } | ||
| .focus-line b { color: var(--accent); font-weight: 600; } | ||
| .focus-x { | ||
| background: transparent; | ||
| border: 0; | ||
| color: var(--muted); | ||
| font-size: 1.2em; | ||
| line-height: 1; | ||
| cursor: pointer; | ||
| padding: 0 4px; | ||
| border-radius: 6px; | ||
| } | ||
| .focus-x:hover { color: var(--ink); background: var(--surface-2); } | ||
| /* ===== Themed dropdown (replaces native <select>) ===== */ | ||
| .ts-wrap { | ||
| position: relative; | ||
| display: inline-block; | ||
| max-width: 60%; | ||
| min-width: 140px; | ||
| } | ||
| .ts-wrap.ts-full { display: block; max-width: none; width: 100%; flex: 1; } | ||
| .ts-btn { | ||
| width: 100%; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 8px; | ||
| background: var(--surface); | ||
| color: var(--ink); | ||
| border: 1px solid var(--line); | ||
| border-radius: 10px; | ||
| padding: 8px 10px; | ||
| font-size: 0.85em; | ||
| font-family: inherit; | ||
| cursor: pointer; | ||
| text-align: left; | ||
| transition: border-color 0.18s, box-shadow 0.18s; | ||
| } | ||
| .ts-btn:hover { border-color: var(--accent); } | ||
| .ts-wrap.open .ts-btn, | ||
| .ts-btn:focus { | ||
| border-color: var(--accent); | ||
| box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 18%, transparent); | ||
| outline: none; | ||
| } | ||
| .ts-label { | ||
| flex: 1; | ||
| overflow: hidden; | ||
| white-space: nowrap; | ||
| text-overflow: ellipsis; | ||
| } | ||
| .ts-caret { | ||
| width: 14px; | ||
| height: 14px; | ||
| flex: 0 0 auto; | ||
| opacity: 0.7; | ||
| transition: transform 0.18s; | ||
| } | ||
| .ts-wrap.open .ts-caret { transform: rotate(180deg); } | ||
| .ts-menu { | ||
| position: absolute; | ||
| top: calc(100% + 4px); | ||
| left: 0; | ||
| right: 0; | ||
| z-index: 50; | ||
| max-height: 240px; | ||
| overflow-y: auto; | ||
| background: var(--surface); | ||
| color: var(--ink); | ||
| border: 1px solid var(--line); | ||
| border-radius: 10px; | ||
| box-shadow: 0 8px 24px rgba(0,0,0,0.18); | ||
| padding: 4px; | ||
| } | ||
| .ts-menu[hidden] { display: none; } | ||
| .ts-item { | ||
| padding: 8px 10px; | ||
| border-radius: 6px; | ||
| font-size: 0.85em; | ||
| cursor: pointer; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| transition: background 0.12s, color 0.12s; | ||
| } | ||
| .ts-item:hover { background: color-mix(in oklab, var(--accent) 14%, transparent); } | ||
| .ts-item.active { | ||
| background: var(--accent); | ||
| color: var(--accent-ink); | ||
| font-weight: 600; | ||
| } | ||
| /* Scrollbar inside menu picks up theme */ | ||
| .ts-menu::-webkit-scrollbar { width: 8px; } | ||
| .ts-menu::-webkit-scrollbar-track { background: transparent; } | ||
| .ts-menu::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; } | ||
| .ts-menu::-webkit-scrollbar-thumb:hover { background: var(--muted); } | ||
| /* ===== Smart Location card ===== */ | ||
| .loc-card { | ||
| background: var(--surface); | ||
| border: 1px solid var(--line); | ||
| border-radius: 12px; | ||
| padding: 12px; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 10px; | ||
| } | ||
| .loc-current { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 8px; | ||
| } | ||
| .loc-resolved { | ||
| font-weight: 600; | ||
| color: var(--ink); | ||
| font-size: 0.95em; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
| .loc-chip { | ||
| flex: 0 0 auto; | ||
| font-size: 0.72em; | ||
| font-weight: 600; | ||
| padding: 3px 8px; | ||
| border-radius: 999px; | ||
| background: color-mix(in oklab, var(--accent) 18%, transparent); | ||
| color: var(--ink); | ||
| border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent); | ||
| } | ||
| .seg { | ||
| display: flex; | ||
| background: var(--surface-2); | ||
| border: 1px solid var(--line); | ||
| border-radius: 10px; | ||
| padding: 3px; | ||
| gap: 2px; | ||
| } | ||
| .seg-btn { | ||
| flex: 1; | ||
| background: transparent; | ||
| border: 0; | ||
| color: var(--muted); | ||
| padding: 6px 10px; | ||
| border-radius: 8px; | ||
| font-size: 0.82em; | ||
| font-weight: 600; | ||
| font-family: inherit; | ||
| cursor: pointer; | ||
| transition: background 0.15s, color 0.15s; | ||
| } | ||
| .seg-btn:hover { color: var(--ink); } | ||
| .seg-btn.active { | ||
| background: var(--accent); | ||
| color: var(--accent-ink); | ||
| } | ||
| .loc-panel { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 8px; | ||
| } | ||
| .loc-panel .row { border-top: 0; padding: 4px 0; } | ||
| .loc-sub { | ||
| font-size: 0.78em; | ||
| color: var(--muted); | ||
| line-height: 1.4; | ||
| } | ||
| .loc-adv { | ||
| margin-top: 4px; | ||
| border: 1px solid var(--line); | ||
| border-radius: 10px; | ||
| padding: 6px 12px; | ||
| background: var(--surface); | ||
| } | ||
| .loc-adv summary { | ||
| cursor: pointer; | ||
| font-size: 0.82em; | ||
| color: var(--muted); | ||
| padding: 4px 0; | ||
| list-style: none; | ||
| user-select: none; | ||
| } | ||
| .loc-adv summary::-webkit-details-marker { display: none; } | ||
| .loc-adv summary::before { | ||
| content: "›"; | ||
| display: inline-block; | ||
| margin-right: 6px; | ||
| transition: transform 0.18s; | ||
| } | ||
| .loc-adv[open] summary::before { transform: rotate(90deg); } | ||
| .loc-adv[open] summary { color: var(--ink); } |
+501
-51
@@ -17,2 +17,23 @@ // Azkar Extension — Prayer Times + Azkar (Hisn al-Muslim) | ||
| method: 5, | ||
| // Coordinates (preferred when useCoords is true) | ||
| useCoords: false, | ||
| lat: null, | ||
| lng: null, | ||
| locationName: "", | ||
| // Which source produced the current location: gps | map | city | manual | ||
| locationSource: "city", | ||
| locationDetectedAt: null, | ||
| // UI: which source panel is currently visible in settings (not persisted to value) | ||
| locationTab: "city", | ||
| // Advanced (raw lat/lng inputs) collapsed by default | ||
| locationAdvancedOpen: false, | ||
| // Reminders + badge | ||
| reminderEnabled: false, | ||
| reminderMinutes: 10, | ||
| athanEnabled: true, | ||
| reminderPrayers: { Fajr: true, Dhuhr: true, Asr: true, Maghrib: true, Isha: true }, | ||
| iqamaEnabled: false, | ||
| iqamaMinutes: 10, | ||
| badgeEnabled: false, | ||
| // Azkar | ||
| category: "أذكار الصباح", | ||
@@ -26,2 +47,5 @@ autoTime: true, | ||
| // Transient (not persisted) | ||
| let focusedPrayer = null; | ||
| const FONT_MAP = { | ||
@@ -151,2 +175,48 @@ "Noto Naskh Arabic": '"Noto Naskh Arabic", serif', | ||
| // Curated country → cities list for users who prefer choosing from a list | ||
| // instead of entering coordinates. Names match what api.aladhan.com accepts. | ||
| const COUNTRY_CITIES = { | ||
| "Saudi Arabia": ["Mecca","Medina","Riyadh","Jeddah","Dammam","Khobar","Taif","Tabuk","Abha","Khamis Mushait","Jubail","Yanbu","Hail","Buraydah","Najran"], | ||
| "Egypt": ["Cairo","Alexandria","Giza","Mansoura","Tanta","Asyut","Luxor","Aswan","Port Said","Suez","Ismailia","Hurghada","Sharm El Sheikh","Damietta","Zagazig"], | ||
| "United Arab Emirates": ["Dubai","Abu Dhabi","Sharjah","Ajman","Ras Al Khaimah","Fujairah","Al Ain"], | ||
| "Kuwait": ["Kuwait City","Hawalli","Salmiya","Jahra","Farwaniya"], | ||
| "Qatar": ["Doha","Al Wakrah","Al Khor","Al Rayyan"], | ||
| "Bahrain": ["Manama","Muharraq","Riffa","Hamad Town"], | ||
| "Oman": ["Muscat","Salalah","Sohar","Nizwa","Sur"], | ||
| "Jordan": ["Amman","Zarqa","Irbid","Aqaba","Madaba"], | ||
| "Palestine": ["Jerusalem","Gaza","Hebron","Nablus","Ramallah","Bethlehem"], | ||
| "Lebanon": ["Beirut","Tripoli","Sidon","Tyre","Zahle"], | ||
| "Syria": ["Damascus","Aleppo","Homs","Hama","Latakia"], | ||
| "Iraq": ["Baghdad","Basra","Mosul","Erbil","Najaf","Karbala","Sulaymaniyah"], | ||
| "Yemen": ["Sanaa","Aden","Taiz","Hodeidah","Ibb"], | ||
| "Turkey": ["Istanbul","Ankara","Izmir","Bursa","Antalya","Konya","Gaziantep"], | ||
| "Morocco": ["Casablanca","Rabat","Marrakech","Fes","Tangier","Agadir","Meknes"], | ||
| "Algeria": ["Algiers","Oran","Constantine","Annaba","Setif"], | ||
| "Tunisia": ["Tunis","Sfax","Sousse","Kairouan","Bizerte"], | ||
| "Libya": ["Tripoli","Benghazi","Misrata","Sabha"], | ||
| "Sudan": ["Khartoum","Omdurman","Port Sudan","Kassala"], | ||
| "Somalia": ["Mogadishu","Hargeisa","Kismayo","Bosaso"], | ||
| "Pakistan": ["Karachi","Lahore","Islamabad","Rawalpindi","Faisalabad","Multan","Peshawar","Quetta"], | ||
| "India": ["New Delhi","Mumbai","Hyderabad","Bangalore","Chennai","Kolkata","Lucknow","Ahmedabad"], | ||
| "Bangladesh": ["Dhaka","Chittagong","Khulna","Sylhet","Rajshahi"], | ||
| "Indonesia": ["Jakarta","Surabaya","Bandung","Medan","Makassar","Yogyakarta"], | ||
| "Malaysia": ["Kuala Lumpur","George Town","Johor Bahru","Ipoh","Shah Alam"], | ||
| "Singapore": ["Singapore"], | ||
| "Iran": ["Tehran","Mashhad","Isfahan","Shiraz","Tabriz","Qom"], | ||
| "Afghanistan": ["Kabul","Kandahar","Herat","Mazar-i-Sharif"], | ||
| "Nigeria": ["Lagos","Abuja","Kano","Ibadan","Kaduna"], | ||
| "United Kingdom": ["London","Manchester","Birmingham","Leeds","Glasgow","Liverpool","Bradford"], | ||
| "United States": ["New York","Los Angeles","Chicago","Houston","Dallas","Detroit","Minneapolis","Washington","Atlanta"], | ||
| "Canada": ["Toronto","Montreal","Vancouver","Ottawa","Calgary","Edmonton"], | ||
| "France": ["Paris","Marseille","Lyon","Toulouse","Nice","Strasbourg"], | ||
| "Germany": ["Berlin","Hamburg","Munich","Cologne","Frankfurt","Stuttgart"], | ||
| "Netherlands": ["Amsterdam","Rotterdam","The Hague","Utrecht"], | ||
| "Belgium": ["Brussels","Antwerp","Ghent"], | ||
| "Spain": ["Madrid","Barcelona","Valencia","Seville","Granada"], | ||
| "Italy": ["Rome","Milan","Naples","Turin","Florence"], | ||
| "Sweden": ["Stockholm","Gothenburg","Malmö"], | ||
| "Australia": ["Sydney","Melbourne","Brisbane","Perth","Adelaide"], | ||
| "South Africa": ["Johannesburg","Cape Town","Durban","Pretoria"], | ||
| }; | ||
| let state = { ...DEFAULTS }; | ||
@@ -286,7 +356,7 @@ let AZKAR_DATA = null; // raw json | ||
| const cache = state.prayerCache; | ||
| if ( | ||
| !force && cache && | ||
| cache.date === today && cache.city === state.city && | ||
| cache.country === state.country && cache.method === state.method | ||
| ) { | ||
| const useCoords = state.useCoords && state.lat != null && state.lng != null; | ||
| const sameLoc = cache && (useCoords | ||
| ? (cache.lat === state.lat && cache.lng === state.lng) | ||
| : (cache.city === state.city && cache.country === state.country)); | ||
| if (!force && cache && cache.date === today && sameLoc && cache.method === state.method) { | ||
| prayers = cache.timings; | ||
@@ -297,3 +367,8 @@ hijri = cache.hijri; | ||
| try { | ||
| const url = `https://api.aladhan.com/v1/timingsByCity/${ddmmyyyy()}?city=${encodeURIComponent(state.city)}&country=${encodeURIComponent(state.country)}&method=${state.method}`; | ||
| let url; | ||
| if (useCoords) { | ||
| url = `https://api.aladhan.com/v1/timings/${ddmmyyyy()}?latitude=${state.lat}&longitude=${state.lng}&method=${state.method}`; | ||
| } else { | ||
| url = `https://api.aladhan.com/v1/timingsByCity/${ddmmyyyy()}?city=${encodeURIComponent(state.city)}&country=${encodeURIComponent(state.country)}&method=${state.method}`; | ||
| } | ||
| const r = await fetch(url); | ||
@@ -306,10 +381,20 @@ const j = await r.json(); | ||
| hijri = `${h.day} ${h.month.en} ${h.year} AH`; | ||
| state.prayerCache = { date: today, city: state.city, country: state.country, method: state.method, timings: prayers, hijri }; | ||
| state.prayerCache = { | ||
| date: today, city: state.city, country: state.country, | ||
| lat: state.lat, lng: state.lng, method: state.method, | ||
| timings: prayers, hijri, | ||
| }; | ||
| storage.set({ prayerCache: state.prayerCache }); | ||
| lastErr = null; | ||
| } catch (e) { | ||
| lastErr = "Failed to load prayer times — check city/country."; | ||
| lastErr = useCoords | ||
| ? "Failed to load prayer times — check your connection." | ||
| : "Failed to load prayer times — check city/country."; | ||
| } | ||
| } | ||
| function nudgeBackground() { | ||
| try { globalThis.chrome?.runtime?.sendMessage?.({ type: "refresh" }); } catch {} | ||
| } | ||
| function maybeResetDaily() { | ||
@@ -352,2 +437,23 @@ const today = todayKey(); | ||
| function timeToPrayer(name) { | ||
| if (!prayers || !prayers[name]) return null; | ||
| const now = new Date(); | ||
| const nowM = now.getHours() * 60 + now.getMinutes(); | ||
| let m = toMinutes(prayers[name]) - nowM; | ||
| const passed = m < 0; | ||
| if (passed) m += 1440; // next occurrence tomorrow | ||
| return { h: Math.floor(m / 60), m: m % 60, passed }; | ||
| } | ||
| function focusedLine() { | ||
| if (!focusedPrayer || !prayers) return ""; | ||
| const t = timeToPrayer(focusedPrayer); | ||
| if (!t) return ""; | ||
| const dur = t.h > 0 ? `${t.h}h ${t.m}m` : `${t.m}m`; | ||
| const label = t.passed | ||
| ? `<b>${focusedPrayer}</b> prayer was <b>${dur}</b> ago` | ||
| : `<b>${focusedPrayer}</b> prayer in <b>${dur}</b>`; | ||
| return `<div class="focus-line"><span>${label}</span><button class="focus-x" id="focusClose" title="Clear">×</button></div>`; | ||
| } | ||
| function prayerCardHTML() { | ||
@@ -363,10 +469,66 @@ const np = nextPrayer(); | ||
| ${PRAYER_ORDER.map((name) => { | ||
| const active = np && np.name === name; | ||
| const isCurrent = np && np.name === name; | ||
| const isFocus = focusedPrayer === name; | ||
| const t = prayers ? fmt12(prayers[name]) : "—"; | ||
| return `<div class="prayer ${active ? "active" : ""}"><div class="n">${name}</div><div class="t">${t}</div></div>`; | ||
| const cls = ["prayer"]; | ||
| if (isCurrent) cls.push("current"); | ||
| if (isFocus) cls.push("focused"); | ||
| return `<button type="button" class="${cls.join(" ")}" data-prayer="${name}"><div class="n">${name}</div><div class="t">${t}</div></button>`; | ||
| }).join("")} | ||
| </div> | ||
| ${focusedLine()} | ||
| ${lastErr ? `<div class="err">${lastErr}</div>` : ""}`; | ||
| } | ||
| // ---------- themed dropdown (replaces native <select> in our UI) ---------- | ||
| const dCaret = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="ts-caret"><path d="M6 9l6 6 6-6"/></svg>`; | ||
| function dropdownHTML(id, value, options, opts = {}) { | ||
| const cur = options.find((o) => String(o.v) === String(value)); | ||
| const label = cur ? cur.l : (opts.placeholder || "—"); | ||
| return `<div class="ts-wrap${opts.full ? " ts-full" : ""}" data-ts="${id}"> | ||
| <button type="button" class="ts-btn" data-ts-btn aria-haspopup="listbox"> | ||
| <span class="ts-label">${label}</span>${dCaret} | ||
| </button> | ||
| <div class="ts-menu" data-ts-menu role="listbox" hidden> | ||
| ${options.map((o) => `<div class="ts-item${String(o.v) === String(value) ? " active" : ""}" role="option" data-ts-item="${String(o.v).replace(/"/g, """)}">${o.l}</div>`).join("")} | ||
| </div> | ||
| </div>`; | ||
| } | ||
| function wireDropdowns(handlers, root) { | ||
| (root || document).querySelectorAll("[data-ts]").forEach((wrap) => { | ||
| const id = wrap.dataset.ts; | ||
| // Only wire dropdowns this caller actually handles — prevents double-binding | ||
| // when both the global wire() and a scoped wireLocation()/patchAzkarCard() | ||
| // run over overlapping regions (would otherwise toggle the menu twice on | ||
| // the first click, making it appear "frozen"). | ||
| if (!handlers || !(id in handlers)) return; | ||
| const btn = wrap.querySelector("[data-ts-btn]"); | ||
| const menu = wrap.querySelector("[data-ts-menu]"); | ||
| if (!btn || !menu) return; | ||
| btn.addEventListener("click", (e) => { | ||
| e.stopPropagation(); | ||
| const open = !menu.hidden; | ||
| document.querySelectorAll("[data-ts-menu]").forEach((m) => (m.hidden = true)); | ||
| document.querySelectorAll("[data-ts]").forEach((w) => w.classList.remove("open")); | ||
| if (!open) { menu.hidden = false; wrap.classList.add("open"); } | ||
| }); | ||
| menu.querySelectorAll("[data-ts-item]").forEach((it) => | ||
| it.addEventListener("click", (e) => { | ||
| e.stopPropagation(); | ||
| const v = it.dataset.tsItem; | ||
| menu.hidden = true; | ||
| wrap.classList.remove("open"); | ||
| handlers[id]?.(v); | ||
| }) | ||
| ); | ||
| }); | ||
| if (!document._tsOutsideBound) { | ||
| document.addEventListener("click", () => { | ||
| document.querySelectorAll("[data-ts-menu]").forEach((m) => (m.hidden = true)); | ||
| document.querySelectorAll("[data-ts]").forEach((w) => w.classList.remove("open")); | ||
| }); | ||
| document._tsOutsideBound = true; | ||
| } | ||
| } | ||
| function catRowHTML() { | ||
@@ -377,5 +539,3 @@ const list = currentDhikrList(); | ||
| return ` | ||
| <select class="cat-pick" id="catPick"> | ||
| ${CATS.map((c) => `<option value="${c}" ${c === state.category ? "selected" : ""}>${c}</option>`).join("")} | ||
| </select> | ||
| ${dropdownHTML("catPick", state.category, CATS.map((c) => ({ v: c, l: c })), { full: true })} | ||
| <span class="counter">${state.azkarCount} / ${target}</span>`; | ||
@@ -419,2 +579,147 @@ } | ||
| function locationCardHTML() { | ||
| const src = state.locationSource || (state.useCoords ? "manual" : "city"); | ||
| const srcLabel = { gps: "via GPS", map: "via map", city: "via city", manual: "via manual" }[src] || ""; | ||
| const resolved = state.useCoords && state.lat != null | ||
| ? (state.locationName | ||
| ? `${state.locationName}` | ||
| : `${Number(state.lat).toFixed(3)}, ${Number(state.lng).toFixed(3)}`) | ||
| : `${state.city}, ${state.country}`; | ||
| const detectedAt = state.locationDetectedAt | ||
| ? new Date(state.locationDetectedAt).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) | ||
| : null; | ||
| const tab = state.locationTab || src; | ||
| const tabs = [["gps", "GPS"], ["map", "Map"], ["city", "City"]]; | ||
| const panel = | ||
| tab === "gps" | ||
| ? `<div class="loc-panel"> | ||
| <button class="loc-btn primary" id="detectLoc">Detect my location</button> | ||
| ${detectedAt && src === "gps" ? `<div class="loc-sub">Last detected ${detectedAt}</div>` : `<div class="loc-sub">Uses your browser's location. You'll be asked once for permission.</div>`} | ||
| </div>` | ||
| : tab === "map" | ||
| ? `<div class="loc-panel"> | ||
| <button class="loc-btn primary" id="pickMap">Open map picker</button> | ||
| <div class="loc-sub">Click anywhere on the map or search to drop a pin.</div> | ||
| </div>` | ||
| : `<div class="loc-panel"> | ||
| <div class="row"> | ||
| <label>Country</label> | ||
| ${dropdownHTML("country", state.country, Object.keys(COUNTRY_CITIES).map((c) => ({ v: c, l: c })))} | ||
| </div> | ||
| <div class="row"> | ||
| <label>City</label> | ||
| ${dropdownHTML("city", state.city, (COUNTRY_CITIES[state.country] || [state.city]).map((c) => ({ v: c, l: c })))} | ||
| </div> | ||
| </div>`; | ||
| return ` | ||
| <div class="loc-card"> | ||
| <div class="loc-current"> | ||
| <div class="loc-resolved">${resolved}</div> | ||
| ${srcLabel ? `<span class="loc-chip">${srcLabel}</span>` : ""} | ||
| </div> | ||
| <div class="seg" role="tablist"> | ||
| ${tabs.map(([id, lbl]) => `<button type="button" class="seg-btn ${tab === id ? "active" : ""}" data-loc-tab="${id}">${lbl}</button>`).join("")} | ||
| </div> | ||
| ${panel} | ||
| </div> | ||
| `; | ||
| } | ||
| function patchLocation() { | ||
| const el = $("#locRegion"); | ||
| if (!el) { render(); return; } | ||
| setHTML(el, locationCardHTML()); | ||
| wireLocation(); | ||
| } | ||
| function wireLocation() { | ||
| const root = $("#locRegion"); | ||
| // Re-wire country / city dropdowns within the location region | ||
| wireDropdowns({ | ||
| country: async (v) => { | ||
| const cities = COUNTRY_CITIES[v] || []; | ||
| state.country = v; | ||
| state.city = cities[0] || state.city; | ||
| state.useCoords = false; | ||
| state.locationName = ""; | ||
| state.locationSource = "city"; | ||
| state.locationTab = "city"; | ||
| storage.set({ country: state.country, city: state.city, useCoords: false, locationName: "", locationSource: "city", locationTab: "city", prayerCache: null }); | ||
| patchLocation(); | ||
| await loadPrayers(true); | ||
| nudgeBackground(); | ||
| patchPrayerCard(); | ||
| }, | ||
| city: async (v) => { | ||
| state.city = v; | ||
| state.useCoords = false; | ||
| state.locationName = ""; | ||
| state.locationSource = "city"; | ||
| state.locationTab = "city"; | ||
| storage.set({ city: state.city, useCoords: false, locationName: "", locationSource: "city", locationTab: "city", prayerCache: null }); | ||
| patchLocation(); | ||
| await loadPrayers(true); | ||
| nudgeBackground(); | ||
| patchPrayerCard(); | ||
| }, | ||
| }, root); | ||
| // Segmented tabs | ||
| document.querySelectorAll("#locRegion [data-loc-tab]").forEach((b) => | ||
| b.addEventListener("click", () => { | ||
| state.locationTab = b.dataset.locTab; | ||
| storage.set({ locationTab: state.locationTab }); | ||
| patchLocation(); | ||
| }) | ||
| ); | ||
| // Detect (GPS) | ||
| const detect = $("#detectLoc"); | ||
| if (detect) detect.addEventListener("click", () => { | ||
| if (!navigator.geolocation) { alert("Geolocation not supported in this browser."); return; } | ||
| detect.disabled = true; | ||
| detect.textContent = "Detecting…"; | ||
| navigator.geolocation.getCurrentPosition(async (pos) => { | ||
| state.lat = +pos.coords.latitude.toFixed(5); | ||
| state.lng = +pos.coords.longitude.toFixed(5); | ||
| state.useCoords = true; | ||
| state.locationName = ""; | ||
| state.locationSource = "gps"; | ||
| state.locationTab = "gps"; | ||
| state.locationDetectedAt = Date.now(); | ||
| try { | ||
| const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${state.lat}&lon=${state.lng}&zoom=10`); | ||
| const j = await r.json(); | ||
| const a = j.address || {}; | ||
| state.locationName = a.city || a.town || a.village || a.state || ""; | ||
| } catch {} | ||
| storage.set({ | ||
| lat: state.lat, lng: state.lng, useCoords: true, | ||
| locationName: state.locationName, | ||
| locationSource: "gps", locationTab: "gps", | ||
| locationDetectedAt: state.locationDetectedAt, | ||
| prayerCache: null, | ||
| }); | ||
| patchLocation(); | ||
| await loadPrayers(true); | ||
| nudgeBackground(); | ||
| patchPrayerCard(); | ||
| }, (err) => { | ||
| detect.disabled = false; | ||
| detect.textContent = "Detect my location"; | ||
| alert("Couldn't get your location: " + err.message); | ||
| }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); | ||
| }); | ||
| // Map picker (opens new tab) | ||
| const pick = $("#pickMap"); | ||
| if (pick) pick.addEventListener("click", () => { | ||
| const params = new URLSearchParams(); | ||
| if (state.lat != null) params.set("lat", state.lat); | ||
| if (state.lng != null) params.set("lng", state.lng); | ||
| const u = globalThis.chrome?.runtime?.getURL | ||
| ? chrome.runtime.getURL("map.html") + (params.toString() ? "?" + params : "") | ||
| : "map.html"; | ||
| if (globalThis.chrome?.tabs?.create) chrome.tabs.create({ url: u }); | ||
| else window.open(u, "_blank"); | ||
| }); | ||
| } | ||
| function renderSettings() { | ||
@@ -430,17 +735,55 @@ return ` | ||
| <div class="sec">Location</div> | ||
| <div id="locRegion">${locationCardHTML()}</div> | ||
| <details class="loc-adv" ${state.locationAdvancedOpen ? "open" : ""}> | ||
| <summary>Advanced (manual coordinates)</summary> | ||
| <div class="row"> | ||
| <label>Latitude</label> | ||
| <input class="input" id="lat" type="number" step="0.0001" placeholder="e.g. 30.0444" value="${state.lat ?? ""}" /> | ||
| </div> | ||
| <div class="row"> | ||
| <label>Longitude</label> | ||
| <input class="input" id="lng" type="number" step="0.0001" placeholder="e.g. 31.2357" value="${state.lng ?? ""}" /> | ||
| </div> | ||
| </details> | ||
| <div class="row"> | ||
| <label>City</label> | ||
| <input class="input" id="city" value="${state.city}" /> | ||
| <label>Calc method</label> | ||
| ${dropdownHTML("method", state.method, METHODS.map(([v, n]) => ({ v, l: n })))} | ||
| </div> | ||
| <div class="sec">Prayer Reminders</div> | ||
| <div class="row"> | ||
| <label>Country</label> | ||
| <input class="input" id="country" value="${state.country}" /> | ||
| <label>Notify at athan time</label> | ||
| <label class="switch"><input type="checkbox" id="athanEnabled" ${state.athanEnabled ? "checked" : ""}/><span></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <label>Calc method</label> | ||
| <select id="method"> | ||
| ${METHODS.map(([v, n]) => `<option value="${v}" ${v === state.method ? "selected" : ""}>${n}</option>`).join("")} | ||
| </select> | ||
| <label>Notify before athan</label> | ||
| <label class="switch"><input type="checkbox" id="reminderEnabled" ${state.reminderEnabled ? "checked" : ""}/><span></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <label>Minutes before athan</label> | ||
| <input type="range" min="0" max="60" step="1" value="${state.reminderMinutes}" id="reminderMinutes"/> | ||
| <span class="slider-val">${state.reminderMinutes}m</span> | ||
| </div> | ||
| <div class="row"> | ||
| <label>Notify for Iqama (after athan)</label> | ||
| <label class="switch"><input type="checkbox" id="iqamaEnabled" ${state.iqamaEnabled ? "checked" : ""}/><span></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <label>Minutes after athan</label> | ||
| <input type="range" min="5" max="30" step="1" value="${state.iqamaMinutes}" id="iqamaMinutes"/> | ||
| <span class="slider-val">${state.iqamaMinutes}m</span> | ||
| </div> | ||
| <div class="prayer-toggles"> | ||
| ${PRAYER_ORDER.map((p) => ` | ||
| <label class="pt ${state.reminderPrayers?.[p] ? "on" : ""}"> | ||
| <input type="checkbox" data-rp="${p}" ${state.reminderPrayers?.[p] ? "checked" : ""}/>${p} | ||
| </label>`).join("")} | ||
| </div> | ||
| <div class="row"> | ||
| <label>Toolbar countdown badge</label> | ||
| <label class="switch"><input type="checkbox" id="badgeEnabled" ${state.badgeEnabled ? "checked" : ""}/><span></span></label> | ||
| </div> | ||
| <div class="sec">Arabic Font</div> | ||
@@ -491,3 +834,3 @@ <div class="font-grid"> | ||
| </div> | ||
| <div class="desc" style="direction:ltr;text-align:left">Tip: Chrome popups cap around 800×600. For a fully resizable window, open in a tab from the home screen.</div> | ||
| </div> | ||
@@ -555,7 +898,10 @@ `; | ||
| if (cat) { | ||
| const list = currentDhikrList(); | ||
| const z = list[state.azkarIndex] || { count: "1" }; | ||
| const target = parseInt(z.count, 10) || 1; | ||
| const counter = cat.querySelector(".counter"); | ||
| if (counter) counter.textContent = `${state.azkarCount} / ${target}`; | ||
| setHTML(cat, catRowHTML()); | ||
| wireDropdowns({ | ||
| catPick: (v) => { | ||
| state.autoTime = false; | ||
| storage.set({ autoTime: false }); | ||
| update({ category: v, azkarIndex: 0, azkarCount: 0 }); | ||
| }, | ||
| }, cat); | ||
| } | ||
@@ -568,4 +914,21 @@ const nav = $("#navIndicator"); | ||
| const el = $("#prayerRegion"); if (el) setHTML(el, prayerCardHTML()); | ||
| wirePrayerClicks(); | ||
| } | ||
| function wirePrayerClicks() { | ||
| document.querySelectorAll("[data-prayer]").forEach((b) => | ||
| b.addEventListener("click", () => { | ||
| const name = b.dataset.prayer; | ||
| focusedPrayer = focusedPrayer === name ? null : name; | ||
| patchPrayerCard(); | ||
| }) | ||
| ); | ||
| const fx = $("#focusClose"); | ||
| if (fx) fx.addEventListener("click", (e) => { | ||
| e.stopPropagation(); | ||
| focusedPrayer = null; | ||
| patchPrayerCard(); | ||
| }); | ||
| } | ||
| // Settings in-place helpers | ||
@@ -650,9 +1013,22 @@ function patchSettingsActive(attr, value) { | ||
| }); | ||
| const cat = $("#catPick"); | ||
| if (cat) cat.addEventListener("change", (e) => { | ||
| // manual pick disables auto morning/evening swap | ||
| state.autoTime = false; | ||
| storage.set({ autoTime: false }); | ||
| update({ category: e.target.value, azkarIndex: 0, azkarCount: 0 }); | ||
| // Themed dropdowns (replaces native <select>) | ||
| wireDropdowns({ | ||
| catPick: (v) => { | ||
| state.autoTime = false; | ||
| storage.set({ autoTime: false }); | ||
| update({ category: v, azkarIndex: 0, azkarCount: 0 }); | ||
| }, | ||
| method: async (v) => { | ||
| state.method = parseInt(v, 10); | ||
| storage.set({ method: state.method }); | ||
| await loadPrayers(true); render(); | ||
| }, | ||
| }); | ||
| wireLocation(); | ||
| // Track Advanced disclosure open state | ||
| const adv = document.querySelector(".loc-adv"); | ||
| if (adv) adv.addEventListener("toggle", () => { | ||
| state.locationAdvancedOpen = adv.open; | ||
| storage.set({ locationAdvancedOpen: adv.open }); | ||
| }); | ||
| // openTab removed — no longer used | ||
@@ -674,20 +1050,3 @@ const pinBtn = $("#pinBtn"); | ||
| // Settings interactions | ||
| const city = $("#city"); | ||
| if (city) city.addEventListener("change", async (e) => { | ||
| state.city = e.target.value.trim() || "Cairo"; | ||
| storage.set({ city: state.city }); | ||
| await loadPrayers(true); render(); | ||
| }); | ||
| const country = $("#country"); | ||
| if (country) country.addEventListener("change", async (e) => { | ||
| state.country = e.target.value.trim() || "Egypt"; | ||
| storage.set({ country: state.country }); | ||
| await loadPrayers(true); render(); | ||
| }); | ||
| const method = $("#method"); | ||
| if (method) method.addEventListener("change", async (e) => { | ||
| state.method = parseInt(e.target.value, 10); | ||
| storage.set({ method: state.method }); | ||
| await loadPrayers(true); render(); | ||
| }); | ||
| document.querySelectorAll("[data-font]").forEach((b) => | ||
@@ -785,2 +1144,86 @@ b.addEventListener("click", () => { | ||
| }); | ||
| // --- Home: click a prayer to see countdown --- | ||
| wirePrayerClicks(); | ||
| // Location (detect/map/country/city/tabs) wired by wireLocation() above. | ||
| const latI = $("#lat"); | ||
| if (latI) latI.addEventListener("change", async (e) => { | ||
| const v = parseFloat(e.target.value); | ||
| state.lat = Number.isFinite(v) ? v : null; | ||
| state.useCoords = state.lat != null && state.lng != null; | ||
| state.locationSource = "manual"; | ||
| storage.set({ lat: state.lat, useCoords: state.useCoords, locationSource: "manual", prayerCache: null }); | ||
| if (state.useCoords) { await loadPrayers(true); nudgeBackground(); patchLocation(); patchPrayerCard(); } | ||
| }); | ||
| const lngI = $("#lng"); | ||
| if (lngI) lngI.addEventListener("change", async (e) => { | ||
| const v = parseFloat(e.target.value); | ||
| state.lng = Number.isFinite(v) ? v : null; | ||
| state.useCoords = state.lat != null && state.lng != null; | ||
| state.locationSource = "manual"; | ||
| storage.set({ lng: state.lng, useCoords: state.useCoords, locationSource: "manual", prayerCache: null }); | ||
| if (state.useCoords) { await loadPrayers(true); nudgeBackground(); patchLocation(); patchPrayerCard(); } | ||
| }); | ||
| // --- Settings: reminders --- | ||
| const re = $("#reminderEnabled"); | ||
| if (re) re.addEventListener("change", (e) => { | ||
| state.reminderEnabled = e.target.checked; | ||
| storage.set({ reminderEnabled: state.reminderEnabled }); | ||
| nudgeBackground(); | ||
| if (state.reminderEnabled && globalThis.Notification && Notification.permission === "default") { | ||
| try { Notification.requestPermission(); } catch {} | ||
| } | ||
| }); | ||
| const ae = $("#athanEnabled"); | ||
| if (ae) ae.addEventListener("change", (e) => { | ||
| state.athanEnabled = e.target.checked; | ||
| storage.set({ athanEnabled: state.athanEnabled, _sentReminders: {} }); | ||
| nudgeBackground(); | ||
| if (state.athanEnabled && globalThis.Notification && Notification.permission === "default") { | ||
| try { Notification.requestPermission(); } catch {} | ||
| } | ||
| }); | ||
| const rm = $("#reminderMinutes"); | ||
| if (rm) rm.addEventListener("input", (e) => { | ||
| const v = parseInt(e.target.value, 10); | ||
| state.reminderMinutes = v; | ||
| storage.set({ reminderMinutes: v, _sentReminders: {} }); | ||
| patchSliderLabel("reminderMinutes", v + "m"); | ||
| nudgeBackground(); | ||
| }); | ||
| const ie = $("#iqamaEnabled"); | ||
| if (ie) ie.addEventListener("change", (e) => { | ||
| state.iqamaEnabled = e.target.checked; | ||
| storage.set({ iqamaEnabled: state.iqamaEnabled, _sentReminders: {} }); | ||
| nudgeBackground(); | ||
| if (state.iqamaEnabled && globalThis.Notification && Notification.permission === "default") { | ||
| try { Notification.requestPermission(); } catch {} | ||
| } | ||
| }); | ||
| const im = $("#iqamaMinutes"); | ||
| if (im) im.addEventListener("input", (e) => { | ||
| const v = parseInt(e.target.value, 10); | ||
| state.iqamaMinutes = v; | ||
| storage.set({ iqamaMinutes: v, _sentReminders: {} }); | ||
| patchSliderLabel("iqamaMinutes", v + "m"); | ||
| nudgeBackground(); | ||
| }); | ||
| document.querySelectorAll("[data-rp]").forEach((c) => | ||
| c.addEventListener("change", (e) => { | ||
| const name = e.target.dataset.rp; | ||
| state.reminderPrayers = { ...state.reminderPrayers, [name]: e.target.checked }; | ||
| storage.set({ reminderPrayers: state.reminderPrayers }); | ||
| e.target.closest(".pt")?.classList.toggle("on", e.target.checked); | ||
| nudgeBackground(); | ||
| }) | ||
| ); | ||
| const be = $("#badgeEnabled"); | ||
| if (be) be.addEventListener("change", (e) => { | ||
| state.badgeEnabled = e.target.checked; | ||
| storage.set({ badgeEnabled: state.badgeEnabled }); | ||
| nudgeBackground(); | ||
| }); | ||
| } | ||
@@ -792,2 +1235,8 @@ | ||
| state = { ...DEFAULTS, ...data }; | ||
| // Migrate: infer location source for users upgrading from older versions | ||
| if (!data.locationSource) { | ||
| state.locationSource = state.useCoords ? "manual" : "city"; | ||
| state.locationTab = state.locationSource; | ||
| storage.set({ locationSource: state.locationSource, locationTab: state.locationTab }); | ||
| } | ||
| try { await loadAzkar(); } catch (e) { lastErr = "Failed to load azkar data."; } | ||
@@ -802,2 +1251,3 @@ maybeResetDaily(); | ||
| else { patchPrayerCard(); if (switched) patchAzkarCard(); } | ||
| nudgeBackground(); | ||
@@ -804,0 +1254,0 @@ // Smooth countdown + auto morning/evening swap, in-place only. |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet