Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
BIN
public/icons/apple-touch-icon-152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/icons/apple-touch-icon-167.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icons/splash-1170x2532.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/splash-1179x2556.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/splash-1284x2778.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/icons/splash-1290x2796.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/icons/splash-1536x2048.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/icons/splash-1668x2388.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/icons/splash-750x1334.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icons/splash-828x1792.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
97
public/sw.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Freezmo service worker (production only).
|
||||
*
|
||||
* Strategy, deliberately conservative:
|
||||
* - API routes are never cached and never intercepted.
|
||||
* - Navigations: network first, falling back to the cached shell, then /offline.
|
||||
* - /_next/static + icons: stale-while-revalidate (fast, self-healing).
|
||||
* - Everything else: straight to the network.
|
||||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v2";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/offline",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/apple-touch-icon.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(
|
||||
names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
function isCacheableAsset(url) {
|
||||
return (
|
||||
url.pathname.startsWith("/_next/static/") ||
|
||||
url.pathname.startsWith("/icons/") ||
|
||||
url.pathname === "/manifest.webmanifest" ||
|
||||
/\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Never touch the API: keys, notes and generated decks must always be live.
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
cache.put("/", response.clone()).catch(() => {});
|
||||
return response;
|
||||
} catch {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCacheableAsset(url)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone()).catch(() => {});
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? network;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
||||