// app.jsx — Everest mission control entry
const { useState: useStateA, useEffect: useEffectA } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"fontPairing": "editorial",
"accent": "#C9512B",
"density": "regular",
"showTopo": true
}/*EDITMODE-END*/;
// Font pairing presets
const FONT_PAIRINGS = {
editorial: {
label: "Editorial",
display: '"Instrument Serif", Georgia, serif',
sans: '"Geist", ui-sans-serif, system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
google: "Instrument+Serif:ital@0;1|Geist:wght@400;500;600;700|JetBrains+Mono:wght@400;500",
},
newsroom: {
label: "Newsroom",
display: '"Newsreader", Georgia, serif',
sans: '"Manrope", ui-sans-serif, system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
google: "Newsreader:ital,opsz,wght@0,6..72,400;1,6..72,400|Manrope:wght@400;500;600;700|JetBrains+Mono:wght@400;500",
},
classical: {
label: "Classical",
display: '"EB Garamond", Georgia, serif',
sans: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
google: "EB+Garamond:ital,wght@0,400;0,500;1,400|Space+Grotesk:wght@400;500;600;700|JetBrains+Mono:wght@400;500",
},
alpine: {
label: "Alpine",
display: '"Cormorant Garamond", Georgia, serif',
sans: '"Inter Tight", ui-sans-serif, system-ui, sans-serif',
mono: '"IBM Plex Mono", ui-monospace, monospace',
google: "Cormorant+Garamond:ital,wght@0,400;0,500;1,400|Inter+Tight:wght@400;500;600;700|IBM+Plex+Mono:wght@400;500",
},
};
function injectGoogleFont(spec) {
const id = "gf-everest";
const old = document.getElementById(id);
if (old) old.remove();
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
const families = spec.split("|").map(f => "family=" + f).join("&");
link.href = "https://fonts.googleapis.com/css2?" + families + "&display=swap";
document.head.appendChild(link);
}
function App({ onLogout, currentUser }) {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [view, setView] = useStateA("dashboard"); // dashboard | project | notes | receipts | symphony
const [activeProjectId, setActiveProjectId] = useStateA(null);
const [projects, setProjects] = useStateA([]);
const [notes, setNotes] = useStateA([]);
const [receipts, setReceipts] = useStateA([]);
const [campFiles, setCampFiles] = useStateA([]);
// ─── Load data from API on mount ──────────────────────────────────
useEffectA(() => {
fetch("/api/v1/projects")
.then(r => r.json())
.then(data => setProjects(data))
.catch(err => console.error("Failed to load projects:", err));
fetch("/api/v1/notes")
.then(r => r.json())
.then(data => setNotes(data))
.catch(err => console.error("Failed to load notes:", err));
fetch("/api/v1/receipts")
.then(r => r.json())
.then(data => setReceipts(data))
.catch(err => console.error("Failed to load receipts:", err));
fetch("/api/v1/camp-files")
.then(r => r.json())
.then(data => setCampFiles(data))
.catch(err => console.error("Failed to load camp files:", err));
}, []);
// ─── Apply font pairing ────────────────────────────────────────────
useEffectA(() => {
const p = FONT_PAIRINGS[t.fontPairing] || FONT_PAIRINGS.editorial;
injectGoogleFont(p.google);
document.documentElement.style.setProperty("--font-display", p.display);
document.documentElement.style.setProperty("--font-sans", p.sans);
document.documentElement.style.setProperty("--font-mono", p.mono);
}, [t.fontPairing]);
// ─── Apply accent ──────────────────────────────────────────────────
useEffectA(() => {
document.documentElement.style.setProperty("--accent", t.accent);
}, [t.accent]);
// ─── Apply density ─────────────────────────────────────────────────
useEffectA(() => {
document.documentElement.setAttribute("data-density", t.density);
}, [t.density]);
// ─── Actions ──────────────────────────────────────────────────────
const openProject = (id) => {
setActiveProjectId(id);
setView("project");
window.scrollTo({ top: 0, behavior: "instant" });
};
const backToBase = () => {
setView("dashboard");
window.scrollTo({ top: 0, behavior: "instant" });
};
const switchProject = (id) => {
setActiveProjectId(id);
window.scrollTo({ top: 0, behavior: "instant" });
};
const markWorked = async (id) => {
const today = new Date().toISOString().slice(0, 10);
// Optimistic update
setProjects(ps => ps.map(p => p.id === id
? { ...p, last_worked: "Worked today", last_worked_at: today }
: p));
try {
const r = await fetch(`/api/v1/projects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ last_worked: "Worked today", last_worked_at: today }),
});
if (!r.ok) throw new Error("PATCH failed");
const updated = await r.json();
setProjects(ps => ps.map(p => p.id === id ? updated : p));
} catch (err) {
console.error("Failed to mark worked:", err);
}
};
const addNote = async (text, projectId = null) => {
try {
const r = await fetch("/api/v1/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, project_id: projectId }),
});
if (!r.ok) throw new Error("POST /api/v1/notes failed");
const note = await r.json();
setNotes(ns => [note, ...ns]);
} catch (err) {
console.error("Failed to add note:", err);
}
};
const patchProject = async (id, updates) => {
setProjects(ps => ps.map(p => p.id === id ? { ...p, ...updates } : p));
try {
const r = await fetch(`/api/v1/projects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!r.ok) throw new Error("PATCH failed");
const updated = await r.json();
setProjects(ps => ps.map(p => p.id === id ? updated : p));
} catch (err) {
console.error("Failed to patch project:", err);
}
};
const deleteNote = async (id) => {
setNotes(ns => ns.filter(n => n.id !== id));
try {
await fetch(`/api/v1/notes/${id}`, { method: "DELETE" });
} catch (err) {
console.error("Failed to delete note:", err);
}
};
const uploadReceipt = async (file, projectId) => {
const fd = new FormData();
fd.append("file", file);
if (projectId) fd.append("project_id", projectId);
try {
const r = await fetch("/api/v1/receipts/upload", { method: "POST", body: fd });
if (!r.ok) throw new Error("POST /api/v1/receipts/upload failed");
const receipt = await r.json();
setReceipts(rs => [...rs, receipt]);
} catch (err) {
console.error("Failed to upload receipt:", err);
}
};
const uploadCampFile = async (file, projectId) => {
const fd = new FormData();
fd.append("file", file);
try {
const r = await fetch(`/api/v1/projects/${projectId}/files`, { method: "POST", body: fd });
if (!r.ok) throw new Error("POST /api/v1/projects/{id}/files failed");
const cf = await r.json();
setCampFiles(cfs => [...cfs, cf]);
} catch (err) {
console.error("Failed to upload camp file:", err);
}
};
const deleteCampFile = async (fileId) => {
try {
const r = await fetch(`/api/v1/camp-files/${fileId}`, { method: "DELETE" });
if (!r.ok) throw new Error("DELETE /api/v1/camp-files/{id} failed");
setCampFiles(cfs => cfs.filter(f => f.id !== fileId));
} catch (err) {
console.error("Failed to delete camp file:", err);
}
};
const createProject = async ({ name, description, folder }) => {
const initials = name.split(" ").map(w => w[0] || "").join("").toUpperCase().slice(0, 2) || "XX";
const seq = String(projects.length + 1).padStart(2, "0");
const elevList = ["8,902","9,843","11,247","12,500","13,114","14,505","15,260","16,730"];
const elevation = elevList[Math.floor(Math.random() * elevList.length)];
const body = {
name,
description,
folder: folder || `~/work/${name.toLowerCase().replace(/\s+/g, "-")}/`,
codename: `${initials}–${seq} · ${name.toUpperCase()}`,
elevation,
coords: "",
status: "Ascending",
progress: 0.0,
};
try {
const r = await fetch("/api/v1/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("POST /api/v1/projects failed");
const project = await r.json();
setProjects(ps => [...ps, project]);
} catch (err) {
console.error("Failed to create project:", err);
}
};
const activeProject = projects.find(p => p.id === activeProjectId);
// ─── Adapt API snake_case fields for project.jsx which uses camelCase ──
// project.jsx reads: project.lastWorked, project.endDate, project.startDate
// API returns: last_worked, end_date, start_date
// We normalise by injecting camelCase aliases when passing down.
const normaliseProject = (p) => ({
...p,
lastWorked: p.last_worked,
lastWorkedAt: p.last_worked_at,
endDate: p.end_date,
startDate: p.start_date,
docs: p.docs || [],
team: p.team || [],
notes: p.notes || [],
activity: p.activity || [],
images: p.images || [],
});
const normalisedProjects = projects.map(normaliseProject);
const normalisedActive = activeProject ? normaliseProject(activeProject) : null;
return (
<>