Initial commit: habit tracker PWA with Google OAuth, push notifications
FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine server-rendered frontend. Multi-user via Google OAuth, daily habit tracking, monthly/weekly history views, Web Push reminders via APScheduler, and PWA support (manifest, service worker, offline caching). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function () {
|
||||
navigator.serviceWorker.register("/service-worker.js").catch(function (err) {
|
||||
console.error("서비스워커 등록 실패", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isIos() {
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||
}
|
||||
|
||||
function isStandalone() {
|
||||
return (
|
||||
("standalone" in window.navigator && window.navigator.standalone) ||
|
||||
window.matchMedia("(display-mode: standalone)").matches
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var banner = document.getElementById("ios-install-banner");
|
||||
if (!banner) return;
|
||||
var dismissed = localStorage.getItem("iosInstallBannerDismissed");
|
||||
if (isIos() && !isStandalone() && !dismissed) {
|
||||
banner.style.display = "flex";
|
||||
}
|
||||
});
|
||||
|
||||
window.dismissIosInstallBanner = function () {
|
||||
var banner = document.getElementById("ios-install-banner");
|
||||
if (banner) banner.style.display = "none";
|
||||
localStorage.setItem("iosInstallBannerDismissed", "1");
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,27 @@
|
||||
(function () {
|
||||
function initSortable() {
|
||||
var list = document.getElementById("habit-list");
|
||||
if (!list || typeof Sortable === "undefined") return;
|
||||
|
||||
Sortable.create(list, {
|
||||
handle: ".drag-handle",
|
||||
animation: 150,
|
||||
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
|
||||
fallbackTolerance: 3,
|
||||
onEnd: function () {
|
||||
var ids = Array.from(list.children)
|
||||
.map(function (el) { return el.getAttribute("data-habit-id"); })
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
|
||||
fetch("/api/habits/reorder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ habit_ids: ids }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initSortable);
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
(function () {
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = atob(base64);
|
||||
const output = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
output[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function isPushSupported() {
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
async function getSubscriptionState() {
|
||||
if (!isPushSupported()) return "unsupported";
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
return sub ? "subscribed" : "unsubscribed";
|
||||
}
|
||||
|
||||
async function subscribePush() {
|
||||
if (!isPushSupported()) {
|
||||
alert("이 브라우저는 알림을 지원하지 않아요.");
|
||||
return false;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
alert("알림 권한이 허용되지 않았어요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { publicKey } = await fetch("/api/push/vapid-public-key").then((r) => r.json());
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
if (!isPushSupported()) return;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) return;
|
||||
await fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
|
||||
window.habitPush = { getSubscriptionState, subscribePush, unsubscribePush };
|
||||
})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user