Upload files to "web"
This commit is contained in:
3
web/README.md
Normal file
3
web/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## Life Manager Web
|
||||||
|
|
||||||
|
Web only with save/load funtions. For services like Netlify or hosting with Nginx etc. Make you edits and just drop folder.
|
||||||
25
web/manifest.webmanifest
Normal file
25
web/manifest.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Life Manager",
|
||||||
|
"short_name": "LifeMgr",
|
||||||
|
"description": "life organizer with calendar, tags, and backups.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f1014",
|
||||||
|
"theme_color": "#0f1014",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
718
web/script.js
Normal file
718
web/script.js
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
// ======================================================
|
||||||
|
// FILE: static/script.js (Netlify/Static Version)
|
||||||
|
// ======================================================
|
||||||
|
|
||||||
|
// --- 1. The "Life Brain" Definitions ---
|
||||||
|
const CATEGORIES = {
|
||||||
|
"brain-dump": {
|
||||||
|
name: "🧠 Quick Brain Dump",
|
||||||
|
description: "Get it out of your head. Sort it later.",
|
||||||
|
emoji: "📥",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: "What's on your mind?",
|
||||||
|
fields: [
|
||||||
|
{ id: "title", label: "Short Title", type: "text", required: true },
|
||||||
|
{ id: "event_date", label: "Date/Time (Optional)", type: "datetime-local" },
|
||||||
|
{ id: "description", label: "Details (#tags)", type: "textarea", required: true },
|
||||||
|
{ id: "frequency", label: "Is this recurring?", type: "select", options: ["One-Time", "Daily", "Weekly", "Monthly", "Yearly"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
chore: {
|
||||||
|
name: "🧹 Routine / Chore",
|
||||||
|
description: "Cleaning, maintenance, bills.",
|
||||||
|
emoji: "🔄",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: "The Routine",
|
||||||
|
fields: [
|
||||||
|
{ id: "title", label: "What needs doing?", type: "text", placeholder: "e.g. Take out Trash", required: true },
|
||||||
|
{ id: "event_date", label: "Start Date (for recurring calc)", type: "datetime-local" },
|
||||||
|
{ id: "frequency", label: "How often?", type: "select", options: ["One-Time", "Daily", "Weekly", "Monthly", "Yearly"] },
|
||||||
|
{ id: "assignee", label: "Who does this? (Optional)", type: "text", placeholder: "e.g. Dad, Kids" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Details",
|
||||||
|
fields: [
|
||||||
|
{ id: "description", label: "Instructions / Notes (Use #tags)", type: "textarea" },
|
||||||
|
{ id: "tools", label: "Tools/Supplies needed?", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
name: "🛠️ Project / Fix",
|
||||||
|
description: "Something broken or a goal.",
|
||||||
|
emoji: "🚧",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: "The Project",
|
||||||
|
fields: [
|
||||||
|
{ id: "title", label: "Project Name", type: "text", required: true },
|
||||||
|
{ id: "event_date", label: "Deadline / Date", type: "datetime-local" },
|
||||||
|
{ id: "frequency", label: "Timeline", type: "select", options: ["One-Time", "Daily", "Weekly", "Monthly", "Yearly"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The Plan",
|
||||||
|
fields: [
|
||||||
|
{ id: "description", label: "Desired Outcome (#tags)", type: "textarea" },
|
||||||
|
{ id: "budget", label: "Estimated Cost ($)", type: "number" },
|
||||||
|
{ id: "blockers", label: "What is stopping us?", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
name: "📦 Life / Appointments",
|
||||||
|
description: "Car, Health, House, etc..",
|
||||||
|
emoji: "📋",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: "System Info",
|
||||||
|
fields: [
|
||||||
|
{ id: "title", label: "Item Name", type: "text", placeholder: "e.g. Honda Civic", required: true },
|
||||||
|
{ id: "event_date", label: "Next Maintenance/Appt Date", type: "datetime-local" },
|
||||||
|
{ id: "frequency", label: "Maintenance/Appt Cycle", type: "select", options: ["One-Time", "Daily", "Weekly", "Monthly", "Yearly"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reference Data",
|
||||||
|
fields: [
|
||||||
|
{ id: "description", label: "Key Data (VIN, Names, ID #) - Use #tags", type: "textarea" },
|
||||||
|
{ id: "location", label: "Where is it happening/stored?", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let allEntries = [];
|
||||||
|
let editingId = null;
|
||||||
|
let currentStep = 0;
|
||||||
|
let currentCatKey = null;
|
||||||
|
let wizardData = {};
|
||||||
|
|
||||||
|
// NEW: OS-Site Style State
|
||||||
|
let activeTags = new Set();
|
||||||
|
let searchQuery = "";
|
||||||
|
|
||||||
|
// --- Elements ---
|
||||||
|
const grid = document.getElementById("grid");
|
||||||
|
const modal = document.getElementById("modal");
|
||||||
|
const modalBody = document.getElementById("wizard-body");
|
||||||
|
const prevBtn = document.getElementById("prev-btn");
|
||||||
|
const nextBtn = document.getElementById("next-btn");
|
||||||
|
const searchInput = document.getElementById("search-input");
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
fetchEntries();
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", (e) => {
|
||||||
|
searchQuery = e.target.value.trim().toLowerCase();
|
||||||
|
refreshViews();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// --- DATA LAYER (REPLACED RUST BACKEND WITH LOCALSTORAGE) ---
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
const DB_KEY = "lifeman_data";
|
||||||
|
|
||||||
|
// Helper: Generate UUID (Replaces Rust uuid::Uuid::new_v4)
|
||||||
|
function uuidv4() {
|
||||||
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
||||||
|
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEntries() {
|
||||||
|
// OLD: const res = await fetch("/api/entries"); allEntries = await res.json();
|
||||||
|
|
||||||
|
// NEW: Load from LocalStorage
|
||||||
|
const raw = localStorage.getItem(DB_KEY);
|
||||||
|
if (raw) {
|
||||||
|
allEntries = JSON.parse(raw);
|
||||||
|
} else {
|
||||||
|
// Initialize with empty if nothing exists
|
||||||
|
allEntries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort logic (ported from Rust handlers.rs)
|
||||||
|
// Sort: Pinned/Daily first (logic not in JSON but implied), then by created_at desc
|
||||||
|
allEntries.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
|
renderTagBar();
|
||||||
|
refreshViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveData() {
|
||||||
|
// Prepare the payload
|
||||||
|
const payload = {
|
||||||
|
template_type: currentCatKey,
|
||||||
|
title: wizardData.title || "Untitled",
|
||||||
|
description: wizardData.description || "",
|
||||||
|
frequency: wizardData.frequency || "One-Time",
|
||||||
|
event_date: wizardData.event_date ? new Date(wizardData.event_date).toISOString() : null,
|
||||||
|
tags: extractTags(wizardData.description || ""),
|
||||||
|
details: wizardData,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
// --- UPDATE LOGIC ---
|
||||||
|
const index = allEntries.findIndex(e => e.id === editingId);
|
||||||
|
if (index !== -1) {
|
||||||
|
// Merge existing data with new payload
|
||||||
|
allEntries[index] = { ...allEntries[index], ...payload };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- CREATE LOGIC ---
|
||||||
|
const newEntry = {
|
||||||
|
id: uuidv4(),
|
||||||
|
status: "Active",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
allEntries.push(newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to LocalStorage
|
||||||
|
localStorage.setItem(DB_KEY, JSON.stringify(allEntries));
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
fetchEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry(id) {
|
||||||
|
if (confirm("Are you sure you want to remove this?")) {
|
||||||
|
// Filter out the deleted item
|
||||||
|
allEntries = allEntries.filter(e => e.id !== id);
|
||||||
|
// Save
|
||||||
|
localStorage.setItem(DB_KEY, JSON.stringify(allEntries));
|
||||||
|
fetchEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// --- END DATA LAYER ---
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
function refreshViews() {
|
||||||
|
if (currentView === "list") renderGrid();
|
||||||
|
else renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag System ---
|
||||||
|
|
||||||
|
function renderTagBar() {
|
||||||
|
const container = document.getElementById("tag-filter-bar");
|
||||||
|
const tags = new Set();
|
||||||
|
|
||||||
|
allEntries.forEach((entry) => {
|
||||||
|
if (entry.tags) entry.tags.forEach((t) => tags.add(t));
|
||||||
|
const matches = entry.description ? entry.description.match(/#\w+/g) : [];
|
||||||
|
if (matches) matches.forEach((m) => tags.add(m.substring(1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
const allBtn = document.createElement("button");
|
||||||
|
allBtn.className = `chip ${activeTags.size === 0 ? "active" : ""}`;
|
||||||
|
allBtn.textContent = "All";
|
||||||
|
allBtn.onclick = () => {
|
||||||
|
activeTags.clear();
|
||||||
|
renderTagBar();
|
||||||
|
refreshViews();
|
||||||
|
};
|
||||||
|
container.appendChild(allBtn);
|
||||||
|
|
||||||
|
Array.from(tags).sort().forEach((tag) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = `chip ${activeTags.has(tag) ? "active" : ""}`;
|
||||||
|
btn.textContent = `#${tag}`;
|
||||||
|
btn.onclick = () => toggleTag(tag);
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(tag) {
|
||||||
|
if (activeTags.has(tag)) {
|
||||||
|
activeTags.delete(tag);
|
||||||
|
} else {
|
||||||
|
activeTags.add(tag);
|
||||||
|
}
|
||||||
|
renderTagBar();
|
||||||
|
refreshViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filter Logic ---
|
||||||
|
|
||||||
|
function entryMatchesFilter(entry) {
|
||||||
|
const textMatch = !searchQuery ||
|
||||||
|
entry.title.toLowerCase().includes(searchQuery) ||
|
||||||
|
(entry.description && entry.description.toLowerCase().includes(searchQuery));
|
||||||
|
|
||||||
|
if (!textMatch) return false;
|
||||||
|
|
||||||
|
if (activeTags.size === 0) return true;
|
||||||
|
|
||||||
|
const entryTags = new Set(entry.tags || []);
|
||||||
|
const descTags = entry.description ? entry.description.match(/#\w+/g) : [];
|
||||||
|
if (descTags) descTags.forEach(t => entryTags.add(t.substring(1)));
|
||||||
|
|
||||||
|
for (let tag of activeTags) {
|
||||||
|
if (entryTags.has(tag)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
grid.innerHTML = "";
|
||||||
|
const visibleEntries = allEntries.filter(entry => entryMatchesFilter(entry));
|
||||||
|
|
||||||
|
if (visibleEntries.length === 0) {
|
||||||
|
grid.innerHTML = `<div style="text-align:center; grid-column:1/-1; color:#555; margin-top:50px;">
|
||||||
|
<h2>No entries found.</h2>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleEntries.forEach((entry) => {
|
||||||
|
const descWithTags = parseTags(entry.description || "");
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-top">
|
||||||
|
<span style="font-size:1.5rem">${CATEGORIES[entry.template_type]?.emoji || "📄"}</span>
|
||||||
|
<span class="freq-badge freq-${entry.frequency || "One-Time"}">${entry.frequency || "One-Time"}</span>
|
||||||
|
</div>
|
||||||
|
<h3>${entry.title}</h3>
|
||||||
|
<p>${descWithTags}</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn-icon" onclick="openEdit('${entry.id}')">✏️ Edit</button>
|
||||||
|
<button class="btn-icon" style="color:var(--danger)" onclick="deleteEntry('${entry.id}')">🗑️</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.replace(
|
||||||
|
/#(\w+)/g,
|
||||||
|
'<span class="hashtag" onclick="filterByTag(\'$1\')">#$1</span>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByTag(tag) {
|
||||||
|
// Simple wrapper to support the inline onclick in parseTags
|
||||||
|
if (!activeTags.has(tag)) {
|
||||||
|
activeTags.clear();
|
||||||
|
activeTags.add(tag);
|
||||||
|
renderTagBar();
|
||||||
|
refreshViews();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wizard / Editor Logic ---
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
editingId = null;
|
||||||
|
wizardData = {};
|
||||||
|
showCategorySelect();
|
||||||
|
modal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(id) {
|
||||||
|
const entry = allEntries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
editingId = id;
|
||||||
|
currentCatKey = entry.template_type;
|
||||||
|
|
||||||
|
let formattedDate = "";
|
||||||
|
if (entry.event_date) {
|
||||||
|
const d = new Date(entry.event_date);
|
||||||
|
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||||
|
formattedDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
wizardData = {
|
||||||
|
title: entry.title,
|
||||||
|
description: entry.description,
|
||||||
|
frequency: entry.frequency,
|
||||||
|
event_date: formattedDate,
|
||||||
|
...entry.details,
|
||||||
|
};
|
||||||
|
|
||||||
|
startWizard(currentCatKey);
|
||||||
|
modal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCategorySelect() {
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<h2 style="margin-bottom:20px; text-align:center;">What are we organizing?</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;">
|
||||||
|
${Object.entries(CATEGORIES)
|
||||||
|
.map(
|
||||||
|
([key, cat]) => `
|
||||||
|
<div onclick="startWizard('${key}')"
|
||||||
|
style="background:#2c303a; padding:20px; border-radius:12px; cursor:pointer; text-align:center; border:1px solid #333; transition:0.2s;">
|
||||||
|
<div style="font-size:2rem; margin-bottom:10px;">${cat.emoji}</div>
|
||||||
|
<strong>${cat.name}</strong><br>
|
||||||
|
<small style="color:#888">${cat.description}</small>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
prevBtn.classList.add("hidden");
|
||||||
|
nextBtn.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWizard(key) {
|
||||||
|
currentCatKey = key;
|
||||||
|
currentStep = 0;
|
||||||
|
renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep() {
|
||||||
|
const template = CATEGORIES[currentCatKey];
|
||||||
|
const step = template.steps[currentStep];
|
||||||
|
|
||||||
|
let html = `<h2>${step.title}</h2>`;
|
||||||
|
step.fields.forEach((field) => {
|
||||||
|
const val = wizardData[field.id] || "";
|
||||||
|
html += `<div class="form-group"><label>${field.label}</label>`;
|
||||||
|
|
||||||
|
if (field.type === "textarea") {
|
||||||
|
html += `<textarea id="inp_${field.id}" rows="4">${val}</textarea>`;
|
||||||
|
} else if (field.type === "select") {
|
||||||
|
html += `<select id="inp_${field.id}">
|
||||||
|
${field.options.map((o) => `<option value="${o}" ${val === o ? "selected" : ""}>${o}</option>`).join("")}
|
||||||
|
</select>`;
|
||||||
|
} else {
|
||||||
|
html += `<input type="${field.type}" id="inp_${field.id}" value="${val}" placeholder="${field.placeholder || ""}">`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
|
||||||
|
prevBtn.classList.remove("hidden");
|
||||||
|
nextBtn.classList.remove("hidden");
|
||||||
|
nextBtn.innerText =
|
||||||
|
currentStep === template.steps.length - 1
|
||||||
|
? editingId
|
||||||
|
? "Update"
|
||||||
|
: "Save"
|
||||||
|
: "Next";
|
||||||
|
}
|
||||||
|
|
||||||
|
window.moveWizard = async (dir) => {
|
||||||
|
const inputs = modalBody.querySelectorAll("input, select, textarea");
|
||||||
|
inputs.forEach((i) => {
|
||||||
|
const key = i.id.replace("inp_", "");
|
||||||
|
wizardData[key] = i.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = CATEGORIES[currentCatKey];
|
||||||
|
const nextIdx = currentStep + dir;
|
||||||
|
|
||||||
|
if (dir === -1 && currentStep === 0) {
|
||||||
|
if (editingId) closeModal();
|
||||||
|
else showCategorySelect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIdx >= template.steps.length) {
|
||||||
|
await saveData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep = nextIdx;
|
||||||
|
renderStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractTags(text) {
|
||||||
|
const match = text.match(/#\w+/g);
|
||||||
|
return match ? match.map((t) => t.substring(1)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calendar Logic ---
|
||||||
|
|
||||||
|
let currentDate = new Date();
|
||||||
|
let currentView = "list";
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
currentView = view;
|
||||||
|
document.getElementById("view-list").classList.toggle("active", view === "list");
|
||||||
|
document.getElementById("view-cal").classList.toggle("active", view === "calendar");
|
||||||
|
|
||||||
|
if (view === "calendar") {
|
||||||
|
document.getElementById("grid").classList.add("hidden");
|
||||||
|
document.getElementById("calendar-view").classList.remove("hidden");
|
||||||
|
renderCalendar();
|
||||||
|
} else {
|
||||||
|
document.getElementById("grid").classList.remove("hidden");
|
||||||
|
document.getElementById("calendar-view").classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMonth(dir) {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + dir);
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
|
||||||
|
const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ];
|
||||||
|
document.getElementById("cal-month-name").innerText = `${monthNames[month]} ${year}`;
|
||||||
|
|
||||||
|
const daysContainer = document.getElementById("cal-days");
|
||||||
|
daysContainer.innerHTML = "";
|
||||||
|
|
||||||
|
const firstDayIndex = new Date(year, month, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDayIndex; i++) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "cal-day";
|
||||||
|
div.style.background = "#111";
|
||||||
|
daysContainer.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
const dayDiv = document.createElement("div");
|
||||||
|
dayDiv.className = "cal-day";
|
||||||
|
|
||||||
|
if (i === today.getDate() && month === today.getMonth() && year === today.getFullYear()) {
|
||||||
|
dayDiv.classList.add("today");
|
||||||
|
}
|
||||||
|
|
||||||
|
dayDiv.innerHTML = `<span class="day-number">${i}</span>`;
|
||||||
|
|
||||||
|
const dayEvents = getEventsForDay(year, month, i);
|
||||||
|
|
||||||
|
dayEvents.forEach((evt) => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `cal-event type-${evt.type}`;
|
||||||
|
el.innerText = evt.title;
|
||||||
|
el.onclick = (e) => {
|
||||||
|
if(window.innerWidth > 768) {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(evt.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dayDiv.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
dayDiv.onclick = () => {
|
||||||
|
selectMobileDay(dayDiv, dayEvents, i, year, month);
|
||||||
|
};
|
||||||
|
|
||||||
|
daysContainer.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventsForDay(year, month, day) {
|
||||||
|
const targetDate = new Date(year, month, day);
|
||||||
|
const dayOfWeek = targetDate.getDay();
|
||||||
|
const dayOfMonth = day;
|
||||||
|
const targetMonth = month;
|
||||||
|
|
||||||
|
let events = [];
|
||||||
|
|
||||||
|
allEntries.forEach((entry) => {
|
||||||
|
if (!entryMatchesFilter(entry)) return;
|
||||||
|
const freq = entry.frequency || "One-Time";
|
||||||
|
const title = entry.title;
|
||||||
|
const id = entry.id;
|
||||||
|
|
||||||
|
const baseDateStr = entry.event_date || entry.created_at;
|
||||||
|
const baseDate = new Date(baseDateStr);
|
||||||
|
|
||||||
|
if (freq === "One-Time") {
|
||||||
|
if (baseDate.getFullYear() === year && baseDate.getMonth() === month && baseDate.getDate() === day) {
|
||||||
|
events.push({ id, title, type: "one-time", category: entry.template_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (freq === "Daily") {
|
||||||
|
if (targetDate >= new Date(baseDate).setHours(0, 0, 0, 0)) {
|
||||||
|
events.push({ id, title, type: "daily", category: entry.template_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (freq === "Weekly") {
|
||||||
|
if (baseDate.getDay() === dayOfWeek && targetDate >= new Date(baseDate).setHours(0, 0, 0, 0)) {
|
||||||
|
events.push({ id, title, type: "weekly", category: entry.template_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (freq === "Monthly") {
|
||||||
|
if (baseDate.getDate() === dayOfMonth && targetDate >= new Date(baseDate).setHours(0, 0, 0, 0)) {
|
||||||
|
events.push({ id, title, type: "monthly", category: entry.template_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (freq === "Yearly") {
|
||||||
|
if (baseDate.getMonth() === targetMonth && baseDate.getDate() === dayOfMonth && targetDate >= new Date(baseDate).setHours(0, 0, 0, 0)) {
|
||||||
|
events.push({ id, title, type: "yearly", category: entry.template_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMobileDay(el, events, day, year, month) {
|
||||||
|
document.querySelectorAll('.cal-day').forEach(d => d.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
|
||||||
|
const detailBox = document.getElementById('mobile-day-details');
|
||||||
|
const titleEl = document.getElementById('mobile-selected-date-title');
|
||||||
|
const listEl = document.getElementById('mobile-event-list');
|
||||||
|
|
||||||
|
detailBox.classList.remove('hidden');
|
||||||
|
|
||||||
|
const dateObj = new Date(year, month, day);
|
||||||
|
titleEl.innerText = dateObj.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
listEl.innerHTML = `<div style="color:#666; font-style:italic;">No events planned.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = events.map(evt => `
|
||||||
|
<div class="mobile-event-card"
|
||||||
|
style="border-left-color: ${getCategoryColor(evt.type)}"
|
||||||
|
onclick="openEdit('${evt.id}')">
|
||||||
|
<span>${evt.title}</span>
|
||||||
|
<span style="font-size:0.8rem; color:#888;">${evt.category}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
if(window.innerWidth < 600) {
|
||||||
|
detailBox.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryColor(type) {
|
||||||
|
if(type === 'daily') return 'var(--success)';
|
||||||
|
if(type === 'weekly') return 'var(--warning)';
|
||||||
|
if(type === 'monthly') return '#2979ff';
|
||||||
|
if(type === 'yearly') return '#ff4081';
|
||||||
|
return 'var(--accent)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ======================================================
|
||||||
|
// NEW: Data Import / Export Logic
|
||||||
|
// ======================================================
|
||||||
|
|
||||||
|
function downloadData() {
|
||||||
|
if (allEntries.length === 0) {
|
||||||
|
alert("Nothing to save!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Convert data to JSON string
|
||||||
|
const dataStr = JSON.stringify(allEntries, null, 2);
|
||||||
|
|
||||||
|
// 2. Create a Blob (Binary Large Object)
|
||||||
|
const blob = new Blob([dataStr], { type: "application/json" });
|
||||||
|
|
||||||
|
// 3. Create a temporary download link
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
|
||||||
|
// 4. Name the file with today's date
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
a.download = `lifeman_backup_${date}.json`;
|
||||||
|
|
||||||
|
// 5. Trigger download and cleanup
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImport() {
|
||||||
|
// Click the hidden file input programmatically
|
||||||
|
document.getElementById('import-file').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileImport(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// When the file is read...
|
||||||
|
reader.onload = function(e) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(e.target.result);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!Array.isArray(json)) {
|
||||||
|
throw new Error("File content is not a valid list.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask user for strategy
|
||||||
|
const strategy = confirm(
|
||||||
|
"Click OK to MERGE with existing data.\nClick Cancel to REPLACE all data."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (strategy === false) {
|
||||||
|
// REPLACE STRATEGY
|
||||||
|
allEntries = json;
|
||||||
|
localStorage.setItem(DB_KEY, JSON.stringify(allEntries));
|
||||||
|
alert("Database replaced successfully!");
|
||||||
|
} else {
|
||||||
|
// MERGE STRATEGY
|
||||||
|
// We map existing items by ID for easy lookup
|
||||||
|
let count = 0;
|
||||||
|
json.forEach(newItem => {
|
||||||
|
const index = allEntries.findIndex(old => old.id === newItem.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
// Update existing
|
||||||
|
allEntries[index] = newItem;
|
||||||
|
} else {
|
||||||
|
// Add new
|
||||||
|
allEntries.push(newItem);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
localStorage.setItem(DB_KEY, JSON.stringify(allEntries));
|
||||||
|
alert(`Processed ${count} items.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI
|
||||||
|
renderTagBar();
|
||||||
|
refreshViews();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Error reading file: " + err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so you can load the same file again if needed
|
||||||
|
input.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
360
web/style.css
Normal file
360
web/style.css
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
:root {
|
||||||
|
--bg-main: #0f1014;
|
||||||
|
--bg-card: #1b1d24;
|
||||||
|
--accent: #0064ff;
|
||||||
|
--accent-glow: rgb(81, 117, 255);
|
||||||
|
--text-primary: #c9d4e3;
|
||||||
|
--text-secondary: #8f9bb3;
|
||||||
|
--success: #00e676;
|
||||||
|
--warning: #ffab00;
|
||||||
|
--danger: #ff1744;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* Header & Search */
|
||||||
|
/* =========================================
|
||||||
|
HEADER & FILTER STYLES (Responsive & Wrapping)
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Default: Stack vertically for mobile */
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: Side-by-side layout */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
header {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start; /* Aligns to top */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title & Tags Container */
|
||||||
|
header > div:first-child {
|
||||||
|
flex: 1; /* Takes up available space */
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Container */
|
||||||
|
header > div:last-child {
|
||||||
|
flex-shrink: 0; /* Prevents buttons from squishing */
|
||||||
|
margin-top: 5px; /* Aligns nicely with the H1 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(to right, #fff, #aaa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin: 0 0 15px 0; /* Add space below title for the chips */
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Bar - Wrapping List */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap; /* KEY CHANGE: Wraps tags to next line */
|
||||||
|
gap: 8px; /* Consistent spacing */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 50px; /* Fully rounded pill shape */
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #2c303a;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-3px); border-color: #444; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* Card Internals */
|
||||||
|
.card-top { display: flex; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.freq-badge {
|
||||||
|
font-size: 0.7rem; text-transform: uppercase; font-weight: bold; padding: 4px 8px; border-radius: 4px; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.freq-Daily { background: rgba(0, 230, 118, 0.15); color: var(--success); }
|
||||||
|
.freq-Weekly { background: rgba(255, 171, 0, 0.15); color: var(--warning); }
|
||||||
|
.freq-Monthly { background: rgba(41, 121, 255, 0.15); color: #2979ff; }
|
||||||
|
.freq-One-Time { background: #333; color: #aaa; }
|
||||||
|
|
||||||
|
.card h3 { margin: 0 0 10px 0; font-size: 1.2rem; }
|
||||||
|
.card p { color: var(--text-secondary); line-height: 1.5; font-size: 0.95rem; flex-grow: 1; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* The Hash Tag Style */
|
||||||
|
.hashtag {
|
||||||
|
color: #c9d4e3;
|
||||||
|
background: rgba(124, 77, 255, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hashtag:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.card-actions { margin-top: 15px; border-top: 1px solid #2c303a; padding-top: 15px; display: flex; gap: 10px; justify-content: flex-end; }
|
||||||
|
.btn-icon { background: none; border: none; cursor: pointer; color: #666; font-size: 0.9rem; }
|
||||||
|
.btn-icon:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* Wizard Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.8); backdrop-filter: blur(8px);
|
||||||
|
display: flex; justify-content: center; align-items: center;
|
||||||
|
z-index: 1000; opacity: 0; pointer-events: none; transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.modal.open { opacity: 1; pointer-events: all; }
|
||||||
|
.wizard-box {
|
||||||
|
background: #1b1d24; width: 500px; max-width: 90%; border-radius: 20px;
|
||||||
|
padding: 30px; border: 1px solid #333; box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 20px; }
|
||||||
|
.form-group label { display: block; color: var(--text-secondary); margin-bottom: 8px; font-size: 0.9rem; }
|
||||||
|
.form-group input, .form-group textarea, .form-group select {
|
||||||
|
width: 100%; padding: 12px; background: #0f1014; border: 1px solid #333;
|
||||||
|
color: white; border-radius: 8px; font-family: inherit; font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.form-group input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: white; border: none; padding: 10px 24px; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--bg-card); }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
background: #1b1d24;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.toggle-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.toggle-btn.active { background: #333; color: white; }
|
||||||
|
|
||||||
|
/* Calendar Container */
|
||||||
|
.calendar-container {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
.cal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.cal-header h2 { margin: 0; color: var(--accent); }
|
||||||
|
.cal-header button {
|
||||||
|
background: #333; border: none; color: white; width: 40px; height: 40px; border-radius: 50%; cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-header button:hover { background: var(--accent); }
|
||||||
|
|
||||||
|
/* Calendar Grid */
|
||||||
|
.cal-grid-header, .cal-grid-days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: #333; /* Border color */
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
.cal-grid-header div {
|
||||||
|
background: #1b1d24;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cal-day {
|
||||||
|
background: #15171c;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 5px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-day:hover { background: #1f2229; }
|
||||||
|
.cal-day.today { background: rgba(124, 77, 255, 0.05); }
|
||||||
|
.day-number {
|
||||||
|
position: absolute; top: 5px; right: 8px; color: #666; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar Events */
|
||||||
|
.cal-event {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-event.type-one-time { background: var(--accent); color: white; }
|
||||||
|
.cal-event.type-daily { background: rgba(0, 230, 118, 0.2); color: var(--success); border-left: 2px solid var(--success); }
|
||||||
|
.cal-event.type-weekly { background: rgba(255, 171, 0, 0.2); color: var(--warning); border-left: 2px solid var(--warning); }
|
||||||
|
.cal-event.type-monthly { background: rgba(41, 121, 255, 0.2); color: #2979ff; border-left: 2px solid #2979ff; }
|
||||||
|
.cal-event.type-yearly {
|
||||||
|
background: rgba(255, 64, 129, 0.2);
|
||||||
|
color: #ff4081;
|
||||||
|
border-left: 2px solid #ff4081;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event::before {
|
||||||
|
content: '•';
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific colors for categories if you want them */
|
||||||
|
.cal-event[onclick*="brain-dump"] { border-right: 3px solid #e040fb; }
|
||||||
|
.cal-event[onclick*="project"] { border-right: 3px solid #00bcd4; }
|
||||||
|
.cal-event[onclick*="chore"] { border-right: 3px solid #ffab00; }
|
||||||
|
|
||||||
|
#search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* 1. Header controls: Make them smaller */
|
||||||
|
.cal-header h2 { font-size: 1.1rem; }
|
||||||
|
.cal-header button { width: 32px; height: 32px; }
|
||||||
|
|
||||||
|
/* 2. The Grid: Reduce height and font size */
|
||||||
|
.cal-day {
|
||||||
|
min-height: 50px; /* Much shorter than desktop */
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center; /* Center the dots */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. The Events: Transform text bars into DOTS */
|
||||||
|
.cal-event {
|
||||||
|
font-size: 0; /* Hide text */
|
||||||
|
width: 6px; /* Dot width */
|
||||||
|
height: 6px; /* Dot height */
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%; /* Circle */
|
||||||
|
border: none !important; /* Remove the left-border styling */
|
||||||
|
margin: 1px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep colors but apply to background */
|
||||||
|
.cal-event.type-one-time { background: var(--accent); }
|
||||||
|
.cal-event.type-daily { background: var(--success); }
|
||||||
|
.cal-event.type-weekly { background: var(--warning); }
|
||||||
|
.cal-event.type-monthly { background: #2979ff; }
|
||||||
|
.cal-event.type-yearly { background: #ff4081; }
|
||||||
|
|
||||||
|
/* 4. Container for the Dots */
|
||||||
|
/* This forces dots to wrap if there are many */
|
||||||
|
.cal-day {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding-top: 20px; /* Make room for the number */
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. Selected State for Mobile */
|
||||||
|
.cal-day.selected {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for the Details Box below calendar (Visible on mobile) */
|
||||||
|
.mobile-day-details {
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
padding-top: 15px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-day-details h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse card styles for the mobile list items */
|
||||||
|
.mobile-event-card {
|
||||||
|
background: #15171c;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
63
web/sw.js
Normal file
63
web/sw.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// sw.js - simple cache-first service worker
|
||||||
|
|
||||||
|
const CACHE_NAME = 'lifemgr-cache-v1';
|
||||||
|
|
||||||
|
// List everything needed for offline
|
||||||
|
const ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/style.css',
|
||||||
|
'/script.js',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: cache core assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll(ASSETS);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: cleanup old caches if you bump the version
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.map((key) => {
|
||||||
|
if (key !== CACHE_NAME) {
|
||||||
|
return caches.delete(key);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: cache-first strategy
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
|
// Only handle GET
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
// Serve from cache if available, else network
|
||||||
|
return (
|
||||||
|
cached ||
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
// Optional: custom offline response for HTML requests
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
return caches.match('/index.html');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user