Phase 1: Prayer times, Qibla, Quran, Hijri, Tasbih, 99 Names PWA

This commit is contained in:
Hermes Bot
2026-08-06 11:25:13 +08:00
commit 4def05d0ef
13 changed files with 1035 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "muslimpro-clone-phase1",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.20.0",
"vite": "^6.2.0"
},
"dependencies": {
"@vitejs/plugin-pwa": "^0.21.0",
"workbox-precaching": "^7.3.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f766e"/>
<stop offset="100%" style="stop-color:#0d9488"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#g)"/>
<text x="50" y="68" text-anchor="middle" fill="white" font-family="Georgia,serif" font-size="52" font-weight="bold">ن</text>
</svg>

After

Width:  |  Height:  |  Size: 469 B

+148
View File
@@ -0,0 +1,148 @@
<script>
import PrayerTimes from './lib/PrayerTimes.svelte';
import Qibla from './lib/Qibla.svelte';
import Quran from './lib/Quran.svelte';
import Hijri from './lib/Hijri.svelte';
import Tasbih from './lib/Tasbih.svelte';
import Names99 from './lib/Names99.svelte';
const tabs = ['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', '99 Names'];
const icons = ['🕌', '🧭', '📖', '📅', '📿', '💚'];
let activeTab = $state(0);
function handleKeydown(e) {
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="app">
<header>
<h1>Nūr</h1>
<span class="subtitle">Muslim Companion</span>
</header>
<nav>
{#each tabs as tab, i}
<button
class="tab"
class:active={activeTab === i}
onclick={() => activeTab = i}
aria-label={tab}
>
<span class="tab-icon">{icons[i]}</span>
<span class="tab-label">{tab}</span>
</button>
{/each}
</nav>
<main>
{#if activeTab === 0}
<PrayerTimes />
{:else if activeTab === 1}
<Qibla />
{:else if activeTab === 2}
<Quran />
{:else if activeTab === 3}
<Hijri />
{:else if activeTab === 4}
<Tasbih />
{:else if activeTab === 5}
<Names99 />
{/if}
</main>
</div>
<style>
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0fdfa;
color: #134e4a;
min-height: 100dvh;
overflow-x: hidden;
}
.app {
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
display: flex;
flex-direction: column;
padding-bottom: 80px;
}
header {
text-align: center;
padding: 20px 16px 8px;
background: linear-gradient(135deg, #0f766e, #0d9488);
color: white;
border-radius: 0 0 24px 24px;
}
header h1 { font-size: 1.8rem; font-weight: 700; }
.subtitle { font-size: 0.8rem; opacity: 0.85; }
nav {
display: flex;
gap: 4px;
padding: 12px 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
justify-content: center;
flex-wrap: wrap;
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 10px;
border: none;
background: transparent;
border-radius: 12px;
cursor: pointer;
font-size: 0.7rem;
color: #5b8c85;
transition: all 0.2s;
min-width: 56px;
}
.tab.active {
background: #ccfbf1;
color: #0f766e;
font-weight: 600;
}
.tab-icon { font-size: 1.3rem; }
.tab-label { white-space: nowrap; }
main {
flex: 1;
padding: 0 12px 16px;
}
:global(.card) {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(15, 118, 110, 0.08);
}
:global(.card h2) {
font-size: 1rem;
color: #0f766e;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
:global(button.primary) {
background: linear-gradient(135deg, #0f766e, #0d9488);
color: white;
border: none;
padding: 10px 20px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
:global(button.primary:active) { opacity: 0.85; }
</style>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f766e" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Nūr — Muslim Companion</title>
<link rel="preconnect" href="https://api.alquran.cloud" crossorigin />
</head>
<body>
<div id="app"></div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<script>
let hijriDate = $state(null);
let gregorian = $state('');
let loading = $state(true);
async function loadHijri() {
try {
const today = new Date();
const d = String(today.getDate()).padStart(2, '0');
const m = String(today.getMonth() + 1).padStart(2, '0');
const y = today.getFullYear();
gregorian = `${d}/${m}/${y}`;
const url = `https://api.aladhan.com/v1/gToH/${d}-${m}-${y}`;
const res = await fetch(url);
const data = await res.json();
if (data.code === 200) {
hijriDate = data.data.hijri;
}
loading = false;
} catch (e) {
loading = false;
}
}
$effect(() => { loadHijri(); });
</script>
<div class="card">
<h2>📅 Hijri Calendar</h2>
{#if loading}
<p style="color:#5b8c85">⏳ Loading…</p>
{:else if hijriDate}
<div class="date-display">
<div class="hijri-big">
<span class="hijri-day">{hijriDate.day}</span>
<span class="hijri-month">{hijriDate.month.en}</span>
<span class="hijri-year">{hijriDate.year} AH</span>
</div>
<div class="gregorian-row">
<span>Gregorian: {gregorian}</span>
</div>
<div class="hijri-info">
<div class="info-row"><span>Weekday</span><span>{hijriDate.weekday.en}</span></div>
<div class="info-row"><span>Month #</span><span>{hijriDate.month.number}</span></div>
<div class="info-row"><span>Days in month</span><span>{hijriDate.month.days}</span></div>
<div class="info-row"><span>Designation</span><span>{hijriDate.designation.expanded}</span></div>
</div>
</div>
{/if}
</div>
<style>
.date-display { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.hijri-big {
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, #0f766e, #0d9488);
color: white;
border-radius: 16px;
padding: 24px 32px;
width: 100%;
text-align: center;
}
.hijri-day { font-size: 2.8rem; font-weight: 700; line-height: 1; }
.hijri-month { font-size: 1.2rem; opacity: 0.9; margin-top: 4px; }
.hijri-year { font-size: 0.9rem; opacity: 0.75; margin-top: 2px; }
.gregorian-row { font-size: 0.85rem; color: #5b8c85; }
.hijri-info { width: 100%; display: flex; flex-direction: column; gap: 4px; }
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #f0fdfa;
border-radius: 8px;
font-size: 0.85rem;
color: #134e4a;
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script>
const names = [
{ no: 1, ar: 'الرَّحْمَنُ', en: 'Ar-Rahman', meaning: 'The Most Merciful' },
{ no: 2, ar: 'الرَّحِيمُ', en: 'Ar-Raheem', meaning: 'The Most Compassionate' },
{ no: 3, ar: 'الْمَلِكُ', en: 'Al-Malik', meaning: 'The King' },
{ no: 4, ar: 'الْقُدُّوسُ', en: 'Al-Quddus', meaning: 'The Most Holy' },
{ no: 5, ar: 'السَّلَامُ', en: 'As-Salam', meaning: 'The Source of Peace' },
{ no: 6, ar: 'الْمُؤْمِنُ', en: 'Al-Mu\'min', meaning: 'The Guardian of Faith' },
{ no: 7, ar: 'الْمُهَيْمِنُ', en: 'Al-Muhaymin', meaning: 'The Protector' },
{ no: 8, ar: 'الْعَزِيزُ', en: 'Al-Aziz', meaning: 'The Almighty' },
{ no: 9, ar: 'الْجَبَّارُ', en: 'Al-Jabbar', meaning: 'The Compeller' },
{ no: 10, ar: 'الْمُتَكَبِّرُ', en: 'Al-Mutakabbir', meaning: 'The Supreme' },
{ no: 11, ar: 'الْخَالِقُ', en: 'Al-Khaliq', meaning: 'The Creator' },
{ no: 12, ar: 'الْبَارِئُ', en: 'Al-Bari', meaning: 'The Maker' },
{ no: 13, ar: 'الْمُصَوِّرُ', en: 'Al-Musawwir', meaning: 'The Fashioner' },
{ no: 14, ar: 'الْغَفَّارُ', en: 'Al-Ghaffar', meaning: 'The Forgiving' },
{ no: 15, ar: 'الْقَهَّارُ', en: 'Al-Qahhar', meaning: 'The Subduer' },
{ no: 16, ar: 'الْوَهَّابُ', en: 'Al-Wahhab', meaning: 'The Bestower' },
{ no: 17, ar: 'الرَّزَّاقُ', en: 'Ar-Razzaq', meaning: 'The Provider' },
{ no: 18, ar: 'الْفَتَّاحُ', en: 'Al-Fattah', meaning: 'The Opener' },
{ no: 19, ar: 'الْعَلِيمُ', en: 'Al-'Aleem', meaning: 'The All-Knowing' },
{ no: 20, ar: 'الْقَابِضُ', en: 'Al-Qabid', meaning: 'The Constrictor' },
{ no: 21, ar: 'الْبَاسِطُ', en: 'Al-Basit', meaning: 'The Expander' },
{ no: 22, ar: 'الْخَافِضُ', en: 'Al-Khafid', meaning: 'The Abaser' },
{ no: 23, ar: 'الرَّافِعُ', en: 'Ar-Rafi', meaning: 'The Exalter' },
{ no: 24, ar: 'الْمُعِزُّ', en: 'Al-Mu\'izz', meaning: 'The Bestower of Honor' },
{ no: 25, ar: 'الْمُذِلُّ', en: 'Al-Mudhill', meaning: 'The Humiliator' },
{ no: 26, ar: 'السَّمِيعُ', en: 'As-Sami', meaning: 'The All-Hearing' },
{ no: 27, ar: 'الْبَصِيرُ', en: 'Al-Basir', meaning: 'The All-Seeing' },
{ no: 28, ar: 'الْحَكَمُ', en: 'Al-Hakam', meaning: 'The Judge' },
{ no: 29, ar: 'الْعَدْلُ', en: 'Al-'Adl', meaning: 'The Just' },
{ no: 30, ar: 'اللَّطِيفُ', en: 'Al-Latif', meaning: 'The Subtle One' },
{ no: 31, ar: 'الْخَبِيرُ', en: 'Al-Khabir', meaning: 'The All-Aware' },
{ no: 32, ar: 'الْحَلِيمُ', en: 'Al-Halim', meaning: 'The Forbearing' },
{ no: 33, ar: 'الْعَظِيمُ', en: 'Al-'Azim', meaning: 'The Magnificent' },
{ no: 34, ar: 'الْغَفُورُ', en: 'Al-Ghafur', meaning: 'The All-Forgiving' },
{ no: 35, ar: 'الشَّكُورُ', en: 'Ash-Shakur', meaning: 'The Appreciative' },
{ no: 36, ar: 'الْعَلِيُّ', en: 'Al-'Aliyy', meaning: 'The Most High' },
{ no: 37, ar: 'الْكَبِيرُ', en: 'Al-Kabir', meaning: 'The Most Great' },
{ no: 38, ar: 'الْحَفِيظُ', en: 'Al-Hafiz', meaning: 'The Preserver' },
{ no: 39, ar: 'الْمُقِيتُ', en: 'Al-Muqit', meaning: 'The Sustainer' },
{ no: 40, ar: 'الْحَسِيبُ', en: 'Al-Hasib', meaning: 'The Reckoner' },
{ no: 41, ar: 'الْجَلِيلُ', en: 'Al-Jalil', meaning: 'The Majestic' },
{ no: 42, ar: 'الْكَرِيمُ', en: 'Al-Karim', meaning: 'The Generous' },
{ no: 43, ar: 'الرَّقِيبُ', en: 'Ar-Raqib', meaning: 'The Watchful' },
{ no: 44, ar: 'الْمُجِيبُ', en: 'Al-Mujib', meaning: 'The Responsive' },
{ no: 45, ar: 'الْوَاسِعُ', en: 'Al-Wasi', meaning: 'The All-Encompassing' },
{ no: 46, ar: 'الْحَكِيمُ', en: 'Al-Hakim', meaning: 'The All-Wise' },
{ no: 47, ar: 'الْوَدُودُ', en: 'Al-Wadud', meaning: 'The Loving' },
{ no: 48, ar: 'الْمَجِيدُ', en: 'Al-Majid', meaning: 'The Glorious' },
{ no: 49, ar: 'الْبَاعِثُ', en: 'Al-Ba\'ith', meaning: 'The Resurrector' },
{ no: 50, ar: 'الشَّهِيدُ', en: 'Ash-Shahid', meaning: 'The Witness' },
{ no: 51, ar: 'الْحَقُّ', en: 'Al-Haqq', meaning: 'The Truth' },
{ no: 52, ar: 'الْوَكِيلُ', en: 'Al-Wakil', meaning: 'The Trustee' },
{ no: 53, ar: 'الْقَوِيُّ', en: 'Al-Qawiyy', meaning: 'The Most Strong' },
{ no: 54, ar: 'الْمَتِينُ', en: 'Al-Matin', meaning: 'The Firm' },
{ no: 55, ar: 'الْوَلِيُّ', en: 'Al-Waliyy', meaning: 'The Protecting Friend' },
{ no: 56, ar: 'الْحَمِيدُ', en: 'Al-Hamid', meaning: 'The Praiseworthy' },
{ no: 57, ar: 'الْمُحْصِي', en: 'Al-Muhsi', meaning: 'The Reckoner' },
{ no: 58, ar: 'الْمُبْدِئُ', en: 'Al-Mubdi', meaning: 'The Originator' },
{ no: 59, ar: 'الْمُعِيدُ', en: 'Al-Mu\'id', meaning: 'The Restorer' },
{ no: 60, ar: 'الْمُحْيِي', en: 'Al-Muhyi', meaning: 'The Giver of Life' },
{ no: 61, ar: 'الْمُمِيتُ', en: 'Al-Mumit', meaning: 'The Taker of Life' },
{ no: 62, ar: 'الْحَيُّ', en: 'Al-Hayy', meaning: 'The Ever-Living' },
{ no: 63, ar: 'الْقَيُّومُ', en: 'Al-Qayyum', meaning: 'The Sustainer' },
{ no: 64, ar: 'الْوَاجِدُ', en: 'Al-Wajid', meaning: 'The Finder' },
{ no: 65, ar: 'الْمَاجِدُ', en: 'Al-Majid', meaning: 'The Noble' },
{ no: 66, ar: 'الْوَاحِدُ', en: 'Al-Wahid', meaning: 'The One' },
{ no: 67, ar: 'الْأَحَدُ', en: 'Al-Ahad', meaning: 'The Unique' },
{ no: 68, ar: 'الصَّمَدُ', en: 'As-Samad', meaning: 'The Eternal' },
{ no: 69, ar: 'الْقَادِرُ', en: 'Al-Qadir', meaning: 'The Able' },
{ no: 70, ar: 'الْمُقْتَدِرُ', en: 'Al-Muqtadir', meaning: 'The Omnipotent' },
{ no: 71, ar: 'الْمُقَدِّمُ', en: 'Al-Muqaddim', meaning: 'The Advancer' },
{ no: 72, ar: 'الْمُؤَخِّرُ', en: 'Al-Mu\'akhkhir', meaning: 'The Delayer' },
{ no: 73, ar: 'الْأَوَّلُ', en: 'Al-Awwal', meaning: 'The First' },
{ no: 74, ar: 'الْآخِرُ', en: 'Al-Akhir', meaning: 'The Last' },
{ no: 75, ar: 'الظَّاهِرُ', en: 'Az-Zahir', meaning: 'The Manifest' },
{ no: 76, ar: 'الْبَاطِنُ', en: 'Al-Batin', meaning: 'The Hidden' },
{ no: 77, ar: 'الْوَالِي', en: 'Al-Wali', meaning: 'The Governor' },
{ no: 78, ar: 'الْمُتَعَالِي', en: 'Al-Muta\'ali', meaning: 'The Most Exalted' },
{ no: 79, ar: 'الْبَرُّ', en: 'Al-Barr', meaning: 'The Source of Goodness' },
{ no: 80, ar: 'التَّوَّابُ', en: 'At-Tawwab', meaning: 'The Acceptor of Repentance' },
{ no: 81, ar: 'الْمُنْتَقِمُ', en: 'Al-Muntaqim', meaning: 'The Avenger' },
{ no: 82, ar: 'الْعَفُوُّ', en: 'Al-'Afuww', meaning: 'The Pardoner' },
{ no: 83, ar: 'الرَّؤُوفُ', en: 'Ar-Ra\'uf', meaning: 'The Kind' },
{ no: 84, ar: 'مَالِكُ الْمُلْكِ', en: 'Malik-ul-Mulk', meaning: 'Master of the Kingdom' },
{ no: 85, ar: 'ذُو الْجَلَالِ وَالْإِكْرَامِ', en: 'Dhul-Jalal wal-Ikram', meaning: 'Lord of Majesty & Honor' },
{ no: 86, ar: 'الْمُقْسِطُ', en: 'Al-Muqsit', meaning: 'The Equitable' },
{ no: 87, ar: 'الْجَامِعُ', en: 'Al-Jami', meaning: 'The Gatherer' },
{ no: 88, ar: 'الْغَنِيُّ', en: 'Al-Ghaniyy', meaning: 'The Self-Sufficient' },
{ no: 89, ar: 'الْمُغْنِي', en: 'Al-Mughni', meaning: 'The Enricher' },
{ no: 90, ar: 'الْمَانِعُ', en: 'Al-Mani', meaning: 'The Preventer' },
{ no: 91, ar: 'الضَّارُّ', en: 'Ad-Darr', meaning: 'The Afflictor' },
{ no: 92, ar: 'النَّافِعُ', en: 'An-Nafi', meaning: 'The Benefactor' },
{ no: 93, ar: 'النُّورُ', en: 'An-Nur', meaning: 'The Light' },
{ no: 94, ar: 'الْهَادِي', en: 'Al-Hadi', meaning: 'The Guide' },
{ no: 95, ar: 'الْبَدِيعُ', en: 'Al-Badi', meaning: 'The Originator' },
{ no: 96, ar: 'الْبَاقِي', en: 'Al-Baqi', meaning: 'The Everlasting' },
{ no: 97, ar: 'الْوَارِثُ', en: 'Al-Warith', meaning: 'The Inheritor' },
{ no: 98, ar: 'الرَّشِيدُ', en: 'Ar-Rashid', meaning: 'The Righteous Guide' },
{ no: 99, ar: 'الصَّبُورُ', en: 'As-Sabur', meaning: 'The Patient' },
];
let search = $state('');
let expanded = $state(null);
const filtered = $derived(
search.trim()
? names.filter(n => n.en.toLowerCase().includes(search.toLowerCase()) || n.meaning.toLowerCase().includes(search.toLowerCase()) || n.ar.includes(search))
: names
);
</script>
<div class="card">
<h2>💚 99 Names of Allah</h2>
<input
class="search-input"
type="search"
placeholder="Search names…"
bind:value={search}
/>
<div class="names-grid">
{#each filtered as n}
<button
class="name-card"
class:expanded={expanded === n.no}
onclick={() => expanded = expanded === n.no ? null : n.no}
>
<span class="name-no">{n.no}</span>
<div class="name-body">
<span class="name-ar">{n.ar}</span>
<span class="name-en">{n.en}</span>
{#if expanded === n.no}
<span class="name-meaning">{n.meaning}</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
<style>
.search-input {
width: 100%;
padding: 10px 14px;
border: 1px solid #99d6c9;
border-radius: 10px;
font-size: 0.9rem;
margin-bottom: 12px;
color: #134e4a;
outline: none;
}
.search-input:focus { border-color: #0f766e; }
.names-grid {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 55vh;
overflow-y: auto;
}
.name-card {
display: flex;
gap: 12px;
padding: 10px 12px;
border: none;
background: #f0fdfa;
border-radius: 10px;
cursor: pointer;
text-align: left;
align-items: center;
transition: background 0.15s;
}
.name-card:active { background: #ccfbf1; }
.name-no {
background: #0f766e;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
}
.name-body { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.name-ar {
font-size: 1rem;
color: #134e4a;
font-weight: 600;
direction: rtl;
text-align: right;
}
.name-en { font-size: 0.8rem; color: #0f766e; font-weight: 500; }
.name-meaning { font-size: 0.75rem; color: #5b8c85; margin-top: 2px; }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script>
let lat = $state(null);
let lng = $state(null);
let prayers = $state(null);
let nextPrayer = $state(null);
let loading = $state(true);
let error = $state('');
let method = $state(3); // Muslim World League
let geoLoading = $state(true);
const methods = [
{ id: 3, name: 'Muslim World League' },
{ id: 2, name: 'ISNA (N. America)' },
{ id: 4, name: 'Umm al-Qura' },
{ id: 5, name: 'Egyptian' },
{ id: 1, name: 'Karachi University' },
];
function getLocation() {
if (!navigator.geolocation) {
error = 'Geolocation not supported';
loading = false;
geoLoading = false;
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
lat = pos.coords.latitude;
lng = pos.coords.longitude;
geoLoading = false;
fetchPrayers();
},
() => {
// Default to Makkah if location denied
lat = 21.4225;
lng = 39.8262;
geoLoading = false;
fetchPrayers();
}
);
}
async function fetchPrayers() {
loading = true;
error = '';
try {
const today = new Date().toISOString().split('T')[0];
const url = `https://api.aladhan.com/v1/timings/${today}?latitude=${lat}&longitude=${lng}&method=${method}`;
const res = await fetch(url);
const data = await res.json();
if (data.code === 200) {
prayers = data.data.timings;
nextPrayer = findNextPrayer(data.data.timings, data.data.meta.timezone);
loading = false;
} else {
error = 'Could not load prayer times';
loading = false;
}
} catch (e) {
error = 'Network error — check connection';
loading = false;
}
}
function findNextPrayer(timings, tz) {
const now = new Date();
const names = ['Fajr', 'Sunrise', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
for (const name of names) {
const [h, m] = timings[name].split(':').map(Number);
const pt = new Date(now);
pt.setHours(h, m, 0, 0);
if (pt > now) return { name, time: timings[name] };
}
return { name: 'Fajr', time: timings['Fajr'] + ' (tomorrow)' };
}
$effect(() => { getLocation(); });
</script>
<div class="card">
<h2>🕌 Prayer Times</h2>
{#if geoLoading}
<p style="color:#5b8c85">📍 Getting location…</p>
{:else if loading}
<p style="color:#5b8c85">⏳ Loading prayer times…</p>
{:else if error}
<p style="color:#e11d48">{error}</p>
<button class="primary" onclick={fetchPrayers}>Retry</button>
{:else if prayers}
{#if nextPrayer}
<div class="next-prayer">
<span class="next-label">Next: {nextPrayer.name}</span>
<span class="next-time">{nextPrayer.time}</span>
</div>
{/if}
<div class="prayer-grid">
{#each ['Fajr', 'Sunrise', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'] as name}
<div class="prayer-row" class:active={nextPrayer?.name === name}>
<span class="prayer-name">{name}</span>
<span class="prayer-time">{prayers[name]}</span>
</div>
{/each}
</div>
<div class="method-select">
<label for="method">Calculation method:</label>
<select id="method" bind:value={method} onchange={fetchPrayers}>
{#each methods as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</div>
{/if}
</div>
<style>
.next-prayer {
background: linear-gradient(135deg, #0f766e, #0d9488);
color: white;
border-radius: 12px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.next-label { font-size: 0.8rem; opacity: 0.9; }
.next-time { font-size: 1.6rem; font-weight: 700; }
.prayer-grid {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.prayer-row {
display: flex;
justify-content: space-between;
padding: 10px 14px;
background: #f0fdfa;
border-radius: 10px;
}
.prayer-row.active {
background: #ccfbf1;
font-weight: 600;
}
.prayer-name { color: #0f766e; }
.prayer-time { font-variant-numeric: tabular-nums; }
.method-select {
display: flex;
gap: 8px;
align-items: center;
font-size: 0.75rem;
color: #5b8c85;
}
.method-select select {
border: 1px solid #99d6c9;
border-radius: 6px;
padding: 4px 8px;
font-size: 0.75rem;
color: #0f766e;
background: white;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script>
let bearing = $state(0);
let loading = $state(true);
let error = $state('');
let deviceAlpha = $state(0);
const KAABA = { lat: 21.4225, lng: 39.8262 };
function toRad(d) { return (d * Math.PI) / 180; }
function toDeg(r) { return (r * 180) / Math.PI; }
function calcBearing(lat, lng) {
const dLng = toRad(KAABA.lng - lng);
const y = Math.sin(dLng) * Math.cos(toRad(KAABA.lat));
const x = Math.cos(toRad(lat)) * Math.sin(toRad(KAABA.lat))
- Math.sin(toRad(lat)) * Math.cos(toRad(KAABA.lat)) * Math.cos(dLng);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
function startCompass() {
if (!navigator.geolocation) {
error = 'Geolocation not supported';
loading = false;
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
bearing = calcBearing(pos.coords.latitude, pos.coords.longitude);
loading = false;
},
() => {
bearing = calcBearing(3.1390, 101.6869); // KL default
loading = false;
}
);
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', (e) => {
if (e.webkitCompassHeading !== undefined) {
deviceAlpha = e.webkitCompassHeading;
} else if (e.alpha !== null) {
deviceAlpha = 360 - e.alpha;
}
});
}
}
$effect(() => { startCompass(); });
const needleAngle = $derived(bearing - deviceAlpha);
</script>
<div class="card">
<h2>🧭 Qibla Finder</h2>
{#if loading}
<p style="color:#5b8c85">📍 Getting location…</p>
{:else if error}
<p style="color:#e11d48">{error}</p>
{:else}
<div class="compass-box">
<div class="compass" style="transform: rotate({needleAngle}deg)">
<div class="needle">
<svg viewBox="0 0 60 80" width="40" height="60">
<polygon points="30,0 0,80 30,60 60,80" fill="#0f766e" />
</svg>
</div>
</div>
<div class="bearing-info">
<span class="bearing-num">{Math.round(bearing)}°</span>
<span class="bearing-dir">from North</span>
</div>
<p class="compass-hint">{deviceAlpha === 0 ? '🗺️ Move your device to calibrate compass' : '↗️ Point the needle towards the Kaaba'}</p>
</div>
{/if}
</div>
<style>
.compass-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 10px 0;
}
.compass {
width: 160px;
height: 160px;
border-radius: 50%;
background: #f0fdfa;
border: 3px solid #0f766e;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.needle {
display: flex;
align-items: center;
justify-content: center;
}
.bearing-info {
display: flex;
flex-direction: column;
align-items: center;
}
.bearing-num { font-size: 2rem; font-weight: 700; color: #0f766e; }
.bearing-dir { font-size: 0.8rem; color: #5b8c85; }
.compass-hint { font-size: 0.75rem; color: #94a3a8; text-align: center; }
</style>
+143
View File
@@ -0,0 +1,143 @@
<script>
let surahs = $state([]);
let selectedSurah = $state(null);
let ayahs = $state([]);
let loading = $state(true);
let error = $state('');
async function loadSurahs() {
try {
const res = await fetch('https://api.alquran.cloud/v1/surah');
const data = await res.json();
if (data.code === 200) surahs = data.data;
loading = false;
} catch (e) {
error = 'Network error';
loading = false;
}
}
async function loadSurah(num) {
loading = true;
try {
const res = await fetch(`https://api.alquran.cloud/v1/surah/${num}/en.asad`);
const data = await res.json();
if (data.code === 200) {
selectedSurah = { num, name: data.data.englishName, english: data.data.englishNameTranslation, type: data.data.revelationType };
ayahs = data.data.ayahs;
}
loading = false;
} catch (e) {
error = 'Failed to load surah';
loading = false;
}
}
function goBack() {
selectedSurah = null;
ayahs = [];
}
$effect(() => { loadSurahs(); });
</script>
<div class="card">
<h2>📖 Holy Quran</h2>
{#if loading}
<p style="color:#5b8c85">⏳ Loading…</p>
{:else if error}
<p style="color:#e11d48">{error}</p>
{:else if selectedSurah}
<div class="surah-header">
<button class="back-btn" onclick={goBack}> Surahs</button>
<h3>{selectedSurah.name} ({selectedSurah.english})</h3>
<span class="surah-type">{selectedSurah.type}</span>
</div>
<div class="ayah-list">
{#each ayahs as ayah}
<div class="ayah">
<span class="ayah-num">{ayah.numberInSurah}</span>
<p class="ayah-text">{ayah.text}</p>
</div>
{/each}
</div>
{:else}
<div class="surah-grid">
{#each surahs as s}
<button class="surah-btn" onclick={() => loadSurah(s.number)}>
<span class="surah-num">{s.number}</span>
<span class="surah-name">{s.englishName}</span>
<span class="surah-ayahs">{s.numberOfAyahs} ayahs</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.surah-grid {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.surah-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: none;
background: #f0fdfa;
border-radius: 10px;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
transition: background 0.15s;
}
.surah-btn:active { background: #ccfbf1; }
.surah-num {
background: #0f766e;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.surah-name { font-weight: 600; color: #134e4a; flex: 1; }
.surah-ayahs { font-size: 0.7rem; color: #5b8c85; }
.surah-header { margin-bottom: 12px; }
.surah-header h3 { font-size: 1.1rem; color: #0f766e; margin: 4px 0; }
.surah-type { font-size: 0.7rem; color: #5b8c85; background: #ccfbf1; padding: 2px 8px; border-radius: 4px; }
.back-btn {
border: none;
background: none;
color: #0f766e;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
padding: 4px 0;
}
.ayah-list { max-height: 60vh; overflow-y: auto; }
.ayah {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #e6f5f0;
}
.ayah-num {
color: #0f766e;
font-weight: 600;
font-size: 0.8rem;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
.ayah-text { font-size: 0.9rem; line-height: 1.6; color: #134e4a; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script>
let count = $state(0);
let total = $state(0);
let target = $state(33);
let vibrate = $state(true);
function increment() {
count = (count + 1) % target;
total++;
if (vibrate && navigator.vibrate) navigator.vibrate(15);
}
function reset() {
count = 0;
total = 0;
}
$effect(() => {
function handleSpace(e) {
if (e.code === 'Space' && e.target === document.body) {
e.preventDefault();
increment();
}
}
window.addEventListener('keydown', handleSpace);
return () => window.removeEventListener('keydown', handleSpace);
});
</script>
<div class="card">
<h2>📿 Tasbih Counter</h2>
<div class="tasbih-display">
<div class="counter-ring" onclick={increment}>
<svg viewBox="0 0 120 120" width="160" height="160">
<circle cx="60" cy="60" r="52" fill="none" stroke="#e6f5f0" stroke-width="8" />
<circle
cx="60" cy="60" r="52"
fill="none" stroke="#0f766e" stroke-width="8"
stroke-dasharray={2 * Math.PI * 52}
stroke-dashoffset={2 * Math.PI * 52 * (1 - count / target)}
stroke-linecap="round"
transform="rotate(-90 60 60)"
/>
<text x="60" y="56" text-anchor="middle" fill="#0f766e" font-size="22" font-weight="700">{count}</text>
<text x="60" y="76" text-anchor="middle" fill="#5b8c85" font-size="10">/ {target}</text>
</svg>
</div>
<div class="total-line">
<span>Total today:</span>
<strong>{total}</strong>
</div>
<div class="controls">
<div class="target-select">
<label>Beads:</label>
<select bind:value={target} onchange={() => { count = 0; }}>
<option value={33}>33</option>
<option value={99}>99</option>
<option value={100}>100</option>
<option value={500}>500</option>
</select>
</div>
<button class="primary" onclick={reset}>Reset</button>
</div>
<p class="hint">👆 Tap the circle or press Space to count</p>
</div>
</div>
<style>
.tasbih-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.counter-ring {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.counter-ring:active { transform: scale(0.97); transition: transform 0.1s; }
.total-line { font-size: 0.9rem; color: #5b8c85; }
.controls {
display: flex;
gap: 12px;
align-items: center;
}
.target-select {
display: flex;
gap: 6px;
align-items: center;
font-size: 0.8rem;
color: #5b8c85;
}
.target-select select {
border: 1px solid #99d6c9;
border-radius: 6px;
padding: 4px 8px;
color: #0f766e;
}
.hint { font-size: 0.7rem; color: #94a3a8; }
</style>
+4
View File
@@ -0,0 +1,4 @@
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, { target: document.getElementById('app') });
export default app;
+2
View File
@@ -0,0 +1,2 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from '@vitejs/plugin-pwa';
export default defineConfig({
plugins: [
svelte(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Nūr — Muslim Companion',
short_name: 'Nūr',
description: 'Prayer times, Quran, Qibla, and more — your daily Islamic companion',
theme_color: '#0f766e',
background_color: '#f0fdfa',
display: 'standalone',
orientation: 'portrait-primary',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.alquran\.cloud\/.*/i,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'quran-api', expiration: { maxEntries: 50, maxAgeSeconds: 86400 * 7 } }
}
]
}
})
]
});