Deploy noo (noo.ayushya.dev) / deploy (push) Successful in 15s
Use the app's monochrome icon, add live phone mockups for accounts, locks, sync, sharing and appearance options, a tab list, coming-soon platforms, and a full privacy policy page. Co-Authored-By: Claude Code <noreply@anthropic.com>
791 lines
36 KiB
JavaScript
791 lines
36 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const $ = (s, r = document) => r.querySelector(s);
|
|
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
|
|
/* Lucide icons, drawn from icons.svg (generated from the app's own design
|
|
kit) so every glyph in a mockup is the glyph the app ships. */
|
|
const ic = (name, size = 20, cls = "") =>
|
|
`<svg class="i ${cls}" width="${size}" height="${size}" aria-hidden="true"><use href="icons.svg#i-${name}"/></svg>`;
|
|
|
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Tokens - lib/theme/design_tokens.dart, light + dark, plus the accent
|
|
presets from AppTheme.seedColors with the exact tonal values
|
|
NooColors.fromSeed produces for each.
|
|
--------------------------------------------------------------------- */
|
|
|
|
const PALETTE = {
|
|
light: {
|
|
bg: "#F3EFE6", sf: "#FFFFFF", sf2: "#F6F5F2", sf3: "#EAE8E2", ln: "#EAE8E2",
|
|
fg1: "#0E0D0B", fg2: "#5A5852", fg3: "#7C7A72",
|
|
dg: "#A8202A", dgs: "#FDE7E7", dgf: "#A8202A",
|
|
ok: "#3E7A23", oks: "#EAF5DF", wn: "#8C5A0E", wns: "#FBEFD0", in: "#2940A8", ins: "#E6ECFE",
|
|
scrim: "rgba(14,13,11,0.45)",
|
|
},
|
|
dark: {
|
|
bg: "#0E0D0B", sf: "#25241F", sf2: "#3D3C38", sf3: "#5A5852", ln: "#3D3C38",
|
|
fg1: "#F3EFE6", fg2: "#BDBAB1", fg3: "#9E9B92",
|
|
dg: "#F49E9E", dgs: "#560F18", dgf: "#A8202A",
|
|
ok: "#B7DD9F", oks: "#1E3F10", wn: "#F0C26C", wns: "#4A3208", in: "#B6C3FB", ins: "#1C2B73",
|
|
scrim: "rgba(0,0,0,0.6)",
|
|
},
|
|
};
|
|
|
|
const ACCENTS = [
|
|
{ id: "violet", name: "Default", c: "#8D0DE3", light: ["#8D0DE3", "#ECDFFF"], dark: ["#CBA5FD", "#2F0454"] },
|
|
{ id: "blue", name: "Nextcloud Blue", c: "#0082C9", light: ["#0082C9", "#CEE5FF"], dark: ["#9BCBFB", "#0D4A73"] },
|
|
{ id: "teal", name: "Ocean Teal", c: "#009688", light: ["#009688", "#9EF2E4"], dark: ["#82D5C8", "#005048"] },
|
|
{ id: "purple", name: "Deep Purple", c: "#6750A4", light: ["#6750A4", "#E9DDFF"], dark: ["#CFBDFE", "#4D3D75"] },
|
|
{ id: "amber", name: "Sunset Amber", c: "#E65100", light: ["#E65100", "#FFDBCF"], dark: ["#FFB59A", "#71361E"] },
|
|
{ id: "green", name: "Emerald Green", c: "#2E7D32", light: ["#2E7D32", "#BCF0B4"], dark: ["#A1D39A", "#245024"] },
|
|
// Material You: follows the OS wallpaper, so this is only a sample.
|
|
{ id: "wallpaper", name: "Match wallpaper", c: "#8E4956", light: ["#8E4956", "#FFD9DE"], dark: ["#FFB1BD", "#72333F"], wallpaper: true },
|
|
];
|
|
|
|
function resolveVars(s) {
|
|
const mode = s.theme === "system" ? (darkQuery.matches ? "dark" : "light") : s.theme;
|
|
const p = { ...PALETTE[mode] };
|
|
const a = ACCENTS.find((x) => x.id === s.accent) || ACCENTS[0];
|
|
const [text, soft] = a[mode];
|
|
if (mode === "dark" && s.amoled) p.bg = p.sf = p.sf2 = p.sf3 = "#000000";
|
|
const v = { ...p, ac: a.c, act: text, acs: soft };
|
|
return { mode, css: Object.entries(v).map(([k, val]) => `--${k}:${val}`).join(";") };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Mock content
|
|
--------------------------------------------------------------------- */
|
|
|
|
const TABS = {
|
|
files: { label: "Files", icon: "folder", blurb: "Browse, upload, sort and filter every folder." },
|
|
photos: { label: "Photos", icon: "photos", blurb: "Every image and video, grouped by month." },
|
|
favorites: { label: "Favorites", icon: "star", blurb: "Everything you've starred, in one list." },
|
|
activity: { label: "Activity", icon: "activity", blurb: "What changed and who changed it." },
|
|
trash: { label: "Trash", icon: "trash", blurb: "Restore or permanently delete removed files." },
|
|
shares: { label: "Shares", icon: "share", blurb: "What you've shared and what's shared with you." },
|
|
recent: { label: "Recent", icon: "clock", blurb: "Recently edited and opened, by day." },
|
|
offline: { label: "Offline", icon: "offline", blurb: "Synced folders and files, available without a connection." },
|
|
};
|
|
const TAB_ORDER_DEFAULT = ["files", "photos", "favorites", "offline", "shares", "recent", "activity", "trash"];
|
|
const PINNED_DEFAULT = ["files", "photos", "favorites", "offline", "shares"];
|
|
|
|
const KIND_ICON = { folder: "folder", pdf: "doc", doc: "doc", sheet: "sheet", video: "film", image: "image", zip: "zip" };
|
|
const KIND_VARS = {
|
|
folder: ["acs", "act"], pdf: ["dgs", "dg"], doc: ["ins", "in"], image: ["ins", "in"],
|
|
sheet: ["oks", "ok"], video: ["wns", "wn"], zip: ["sf3", "fg2"],
|
|
};
|
|
|
|
// [name, kind, meta, flags]
|
|
const ROWS = {
|
|
files: [
|
|
["Documents", "folder", "24 items · Sep 20"],
|
|
["Photos", "folder", "1,204 items · Today"],
|
|
["Projects", "folder", "8 items · Sep 19", "shared"],
|
|
["Q3 Planning.pdf", "pdf", "2.4 MB · Yesterday", "fav ok"],
|
|
["Budget 2026.xlsx", "sheet", "186 KB · Sep 18", "ok"],
|
|
["Offsite recap.mp4", "video", "412 MB · Sep 14"],
|
|
["Whiteboard.jpg", "image", "3.1 MB · Sep 12"],
|
|
["Brand brief.docx", "doc", "58 KB · Sep 10"],
|
|
],
|
|
favorites: [
|
|
["Q3 Planning.pdf", "pdf", "2.4 MB · Yesterday", "ok"],
|
|
["Projects", "folder", "8 items · Sep 19", "shared"],
|
|
["Passport scan.pdf", "pdf", "1.1 MB · Aug 30", "ok"],
|
|
["Family budget.xlsx", "sheet", "92 KB · Aug 21", "shared"],
|
|
["Wedding", "folder", "212 items · Jun 14"],
|
|
],
|
|
offline: [
|
|
["Q3 Planning.pdf", "pdf", "Up to date · 2.4 MB", "ok"],
|
|
["Offsite recap.mp4", "video", "Up to date · 412 MB", "ok"],
|
|
["Contracts", "folder", "Syncing 12 of 18 files", "sync"],
|
|
["Passport scan.pdf", "pdf", "Up to date · 1.1 MB", "ok"],
|
|
],
|
|
shares: [
|
|
["Projects", "folder", "Ana Souza · Can edit", "shared"],
|
|
["Offsite photos", "folder", "Leo Park · Can view", "shared"],
|
|
["Q3 Planning.pdf", "pdf", "Ana Souza · Can edit", "shared"],
|
|
],
|
|
recent: [
|
|
["Q3 Planning.pdf", "pdf", "Edited 10:24 · Documents", "fav"],
|
|
["Whiteboard.jpg", "image", "Uploaded 9:40 · Photos"],
|
|
["Brand brief.docx", "doc", "Opened 8:12 · Projects"],
|
|
["Budget 2026.xlsx", "sheet", "Edited yesterday · Documents"],
|
|
],
|
|
trash: [
|
|
["Old notes.txt", "doc", "Deleted today · /Documents"],
|
|
["Draft v1.docx", "doc", "Deleted yesterday · /Projects"],
|
|
["Screenshots", "folder", "Deleted Sep 12 · /Photos"],
|
|
],
|
|
activity: [
|
|
["Ana Souza shared Q3 Planning.pdf with you", "pdf", "10:24"],
|
|
["Leo Park edited Budget 2026.xlsx", "sheet", "9:02"],
|
|
["You uploaded Whiteboard.jpg", "image", "Yesterday"],
|
|
["Ana Souza commented on Projects", "folder", "Yesterday"],
|
|
],
|
|
};
|
|
const PHOTO_TINTS = ["#8DCDE2", "#96B0FD", "#E5D8BD", "#EEEDB3", "#DA9AC7", "#A7D296", "#E9A6A7", "#85D1BD", "#CBA5FD"];
|
|
|
|
const avatar = (txt, tint, size = 36, current = false) =>
|
|
`<span class="av${current ? " av-current" : ""}" style="--s:${size}px;background:${tint}">${esc(txt)}</span>`;
|
|
|
|
const fileRow = (r) => {
|
|
const [name, kind, meta, flags = ""] = r;
|
|
const [bg, fg] = KIND_VARS[kind];
|
|
const f = flags.split(" ");
|
|
const badges =
|
|
(f.includes("fav") ? ic("star", 16, "b-fav") : "") +
|
|
(f.includes("shared") ? ic("users", 16, "b-muted") : "") +
|
|
(f.includes("ok") ? ic("ok", 16, "b-ok") : "") +
|
|
(f.includes("sync") ? ic("sync", 16, "b-sync") : "");
|
|
return `<div class="fr"><span class="ft" style="background:var(--${bg});color:var(--${fg})">${ic(KIND_ICON[kind], 20)}</span>` +
|
|
`<span class="fm"><b>${esc(name)}</b><i>${esc(meta)}</i></span><span class="fb">${badges}</span></div>`;
|
|
};
|
|
|
|
/* ---------------------------------------------------------------------
|
|
The phone: top bar, body, bottom bar (attached / floating / search
|
|
satellite), drawer + avatar menu. Mirrors NooTopBar, NooBottomBar,
|
|
NooDrawer and the avatar menu in the Flutter app (Android layout).
|
|
--------------------------------------------------------------------- */
|
|
|
|
function phoneState(over = {}) {
|
|
return {
|
|
theme: "light", accent: "violet", amoled: false,
|
|
bar: "floating", searchInBar: false, nav: "drawer",
|
|
order: [...TAB_ORDER_DEFAULT], pinned: new Set(PINNED_DEFAULT),
|
|
active: "files", overlay: null, ...over,
|
|
};
|
|
}
|
|
|
|
function visibleTabs(s) {
|
|
const max = s.searchInBar ? 4 : 5;
|
|
return s.order.filter((t) => s.pinned.has(t)).slice(0, max);
|
|
}
|
|
|
|
function renderPhone(host, s) {
|
|
const { css } = resolveVars(s);
|
|
const tabs = visibleTabs(s);
|
|
if (!tabs.includes(s.active)) s.active = tabs[0] || "files";
|
|
const hidden = s.order.filter((t) => !tabs.includes(t));
|
|
const t = TABS[s.active];
|
|
const floating = s.bar === "floating";
|
|
|
|
const bodyRows =
|
|
s.active === "photos"
|
|
? `<div class="np-photos">${PHOTO_TINTS.concat(PHOTO_TINTS).slice(0, 12)
|
|
.map((c, i) => `<span style="background:${c};opacity:${0.65 + ((i * 7) % 4) * 0.1}"></span>`).join("")}</div>`
|
|
: `<div class="np-list">${(ROWS[s.active] || ROWS.files).map(fileRow).join("")}</div>`;
|
|
|
|
const searchLauncher = !s.searchInBar
|
|
? `<span class="np-search">${ic("search", 18)}<span>Search</span></span>`
|
|
: "";
|
|
|
|
const hamburger = s.nav === "drawer"
|
|
? `<button class="np-ib" data-act="menu" aria-label="Open menu">${ic("menu", 24)}</button>`
|
|
: "";
|
|
|
|
const barItem = (id) => {
|
|
const sel = id === s.active;
|
|
const d = TABS[id];
|
|
if (floating && !sel) {
|
|
return `<button class="nb nb-icon" data-tab="${id}" aria-label="${d.label}">${ic(d.icon, 25)}</button>`;
|
|
}
|
|
return `<button class="nb${sel ? " is-sel" : ""}" data-tab="${id}" aria-label="${d.label}">` +
|
|
`<span class="nb-pill">${ic(d.icon, 24)}</span><span class="nb-label">${d.label}</span></button>`;
|
|
};
|
|
const searchItem = !s.searchInBar ? "" : floating
|
|
? `<button class="nb-sat" data-act="search" aria-label="Search">${ic("search", 24)}</button>`
|
|
: "";
|
|
const attachedSearch = s.searchInBar && !floating
|
|
? `<button class="nb nb-search" data-act="search" aria-label="Search"><span class="nb-pill">${ic("search", 24)}</span><span class="nb-label" style="opacity:0">Search</span></button>`
|
|
: "";
|
|
|
|
const bar = `<footer class="np-bar ${floating ? "is-floating" : "is-attached"}">` +
|
|
`<div class="np-bar-row">${tabs.map(barItem).join("")}${attachedSearch}</div>${searchItem}</footer>`;
|
|
|
|
const fab = s.active === "files"
|
|
? `<button class="np-fab" style="bottom:${floating ? 88 : 96}px" aria-label="Create">${ic("plus", 24)}</button>`
|
|
: "";
|
|
|
|
let overlay = "";
|
|
if (s.overlay === "drawer") {
|
|
overlay = `<div class="np-scrim" data-act="close"></div><nav class="np-drawer" aria-label="Navigation">` +
|
|
`<div class="np-drawer-head"><span class="np-mark"></span><b>noo</b></div>` +
|
|
hidden.map((id) => `<button class="np-dr" data-tab="${id}">${ic(TABS[id].icon, 20)}<span>${TABS[id].label}</span></button>`).join("") +
|
|
`<div class="np-sep"></div><button class="np-dr">${ic("settings", 20)}<span>Settings</span></button></nav>`;
|
|
} else if (s.overlay === "avatar") {
|
|
overlay = `<div class="np-scrim" data-act="close"></div><div class="np-pop" role="menu">` +
|
|
`<div class="np-acct">${avatar("A", "#DA9AC7", 32, true)}<span><b>ayushya</b><i>cloud.example.com</i></span>${ic("check", 18, "b-sync")}</div>` +
|
|
`<div class="np-acct">${avatar("D", "#8DCDE2", 32)}<span><b>ayushya.dev</b><i>drive.ayushya.dev</i></span></div>` +
|
|
`<div class="np-sep"></div>` +
|
|
hidden.map((id) => `<button class="np-dr" data-tab="${id}">${ic(TABS[id].icon, 20)}<span>${TABS[id].label}</span></button>`).join("") +
|
|
`<div class="np-sep"></div><button class="np-dr">${ic("settings", 20)}<span>Settings</span></button></div>`;
|
|
}
|
|
|
|
host.innerHTML =
|
|
`<div class="np" style="${css}">` +
|
|
`<div class="np-status"><span>9:41</span><span class="np-status-i">${ic("signal", 14)}${ic("wifi", 14)}${ic("battery", 16)}</span></div>` +
|
|
`<header class="np-top">${hamburger}<h4>${t.label}</h4>${searchLauncher}` +
|
|
`<button class="np-avbtn" data-act="avatar" aria-label="${s.nav === "avatar" ? "Menu" : "Accounts"}">${avatar("A", "#DA9AC7", 36, true)}</button></header>` +
|
|
`<div class="np-body">${bodyRows}</div>${fab}${bar}${overlay}</div>`;
|
|
}
|
|
|
|
function mountPhone(host, state) {
|
|
const rerender = () => renderPhone(host, state);
|
|
host.addEventListener("click", (e) => {
|
|
const tabBtn = e.target.closest("[data-tab]");
|
|
const act = e.target.closest("[data-act]");
|
|
if (tabBtn) {
|
|
state.active = tabBtn.dataset.tab;
|
|
state.overlay = null;
|
|
rerender();
|
|
} else if (act) {
|
|
const a = act.dataset.act;
|
|
if (a === "menu") state.overlay = "drawer";
|
|
else if (a === "avatar") state.overlay = state.nav === "avatar" ? "avatar" : null;
|
|
else if (a === "close") state.overlay = null;
|
|
rerender();
|
|
}
|
|
});
|
|
rerender();
|
|
return rerender;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Hero phone
|
|
--------------------------------------------------------------------- */
|
|
|
|
const heroPhone = $("#heroPhone");
|
|
if (heroPhone) {
|
|
const heroState = phoneState({ theme: "system", bar: "floating" });
|
|
const r = mountPhone(heroPhone, heroState);
|
|
darkQuery.addEventListener?.("change", r);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Appearance lab
|
|
--------------------------------------------------------------------- */
|
|
|
|
const lab = $("#labPhone");
|
|
if (lab) {
|
|
const S = phoneState({ theme: "system" });
|
|
const rerender = mountPhone(lab, S);
|
|
darkQuery.addEventListener?.("change", rerender);
|
|
|
|
// Accent swatches
|
|
const sw = $("#labAccents");
|
|
sw.innerHTML = ACCENTS.map((a) =>
|
|
`<button class="swatch-btn${a.id === S.accent ? " is-active" : ""}" data-accent="${a.id}" aria-label="${a.name}" title="${a.name}" style="--c:${a.c}">${a.wallpaper ? ic("wallpaper", 16) : ""}</button>`
|
|
).join("");
|
|
const accName = $("#labAccentName");
|
|
const syncAccent = () => {
|
|
$$(".swatch-btn", sw).forEach((b) => b.classList.toggle("is-active", b.dataset.accent === S.accent));
|
|
const a = ACCENTS.find((x) => x.id === S.accent);
|
|
accName.textContent = a.wallpaper ? "Match wallpaper (sample colour shown)" : a.name;
|
|
};
|
|
sw.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-accent]");
|
|
if (!b) return;
|
|
S.accent = b.dataset.accent;
|
|
syncAccent();
|
|
rerender();
|
|
});
|
|
syncAccent();
|
|
|
|
// Segmented controls: data-lab="theme|bar|nav" data-val="..."
|
|
$$("[data-lab]").forEach((group) => {
|
|
const key = group.dataset.lab;
|
|
const sync = () => $$("[data-val]", group).forEach((b) => b.classList.toggle("is-active", String(S[key]) === b.dataset.val));
|
|
group.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-val]");
|
|
if (!b) return;
|
|
S[key] = b.dataset.val;
|
|
S.overlay = null;
|
|
sync();
|
|
rerender();
|
|
updateAmoled();
|
|
tabsRender();
|
|
});
|
|
sync();
|
|
});
|
|
|
|
// Toggles: data-toggle="amoled|searchInBar"
|
|
const amoledRow = $("#amoledRow");
|
|
const resolvedMode = () => resolveVars(S).mode;
|
|
function updateAmoled() {
|
|
const isDark = resolvedMode() === "dark";
|
|
amoledRow.classList.toggle("is-disabled", !isDark);
|
|
$("[data-toggle=amoled]").disabled = !isDark;
|
|
}
|
|
$$("[data-toggle]").forEach((t) => {
|
|
const key = t.dataset.toggle;
|
|
t.setAttribute("role", "switch");
|
|
const sync = () => {
|
|
t.classList.toggle("is-on", !!S[key]);
|
|
t.setAttribute("aria-checked", String(!!S[key]));
|
|
};
|
|
t.addEventListener("click", () => {
|
|
S[key] = !S[key];
|
|
sync();
|
|
rerender();
|
|
tabsRender();
|
|
});
|
|
sync();
|
|
});
|
|
darkQuery.addEventListener?.("change", updateAmoled);
|
|
updateAmoled();
|
|
|
|
// Tabs: reorder + pin
|
|
const tabsList = $("#labTabs");
|
|
const tabsNote = $("#labTabsNote");
|
|
function tabsRender() {
|
|
const max = S.searchInBar ? 4 : 5;
|
|
tabsNote.textContent = `Drag to reorder, pin up to ${max} to the bottom bar` + (S.searchInBar ? " (Search takes the fifth slot)" : "");
|
|
tabsList.innerHTML = S.order.map((id) =>
|
|
`<li class="tab-row" data-id="${id}"><span class="grip" data-grip aria-label="Drag to reorder">${ic("grip", 18)}</span>` +
|
|
`<span class="tab-ic">${ic(TABS[id].icon, 20)}</span><span class="tab-label">${TABS[id].label}</span>` +
|
|
`<button class="toggle${S.pinned.has(id) ? " is-on" : ""}" role="switch" aria-checked="${S.pinned.has(id)}" aria-label="Pin ${TABS[id].label}" data-pin="${id}"><i></i></button></li>`
|
|
).join("");
|
|
}
|
|
tabsList.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-pin]");
|
|
if (!b) return;
|
|
const id = b.dataset.pin;
|
|
const max = S.searchInBar ? 4 : 5;
|
|
if (S.pinned.has(id)) {
|
|
if (S.pinned.size <= 1) return;
|
|
S.pinned.delete(id);
|
|
} else {
|
|
if (S.pinned.size >= max) {
|
|
tabsNote.textContent = `The bottom bar holds ${max} tabs - unpin one first`;
|
|
return;
|
|
}
|
|
S.pinned.add(id);
|
|
}
|
|
S.overlay = null;
|
|
tabsRender();
|
|
rerender();
|
|
});
|
|
sortable(tabsList, (ids) => {
|
|
S.order = ids;
|
|
S.overlay = null;
|
|
rerender();
|
|
});
|
|
tabsRender();
|
|
}
|
|
|
|
/* Pointer-driven list reorder: the dragged row moves through the DOM and
|
|
the new order is read back on release. */
|
|
function sortable(list, onChange) {
|
|
list.addEventListener("pointerdown", (e) => {
|
|
const grip = e.target.closest("[data-grip]");
|
|
if (!grip) return;
|
|
const row = grip.closest("[data-id]");
|
|
e.preventDefault();
|
|
row.classList.add("is-dragging");
|
|
grip.setPointerCapture(e.pointerId);
|
|
const move = (ev) => {
|
|
const siblings = $$("[data-id]", list).filter((r) => r !== row);
|
|
const next = siblings.find((r) => {
|
|
const b = r.getBoundingClientRect();
|
|
return ev.clientY < b.top + b.height / 2;
|
|
});
|
|
if (next) list.insertBefore(row, next);
|
|
else list.appendChild(row);
|
|
};
|
|
const up = () => {
|
|
grip.removeEventListener("pointermove", move);
|
|
grip.removeEventListener("pointerup", up);
|
|
grip.removeEventListener("pointercancel", up);
|
|
row.classList.remove("is-dragging");
|
|
onChange($$("[data-id]", list).map((r) => r.dataset.id));
|
|
};
|
|
grip.addEventListener("pointermove", move);
|
|
grip.addEventListener("pointerup", up);
|
|
grip.addEventListener("pointercancel", up);
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Accounts
|
|
--------------------------------------------------------------------- */
|
|
|
|
const acctHost = $("#acctDemo");
|
|
if (acctHost) {
|
|
const accounts = [
|
|
{ u: "ayushya", h: "cloud.example.com", t: "#DA9AC7", l: "A" },
|
|
{ u: "ayushya.dev", h: "drive.ayushya.dev", t: "#8DCDE2", l: "D" },
|
|
{ u: "family", h: "nc.home.lan", t: "#A7D296", l: "F" },
|
|
];
|
|
let current = 0;
|
|
const draw = () => {
|
|
acctHost.innerHTML =
|
|
accounts.map((a, i) =>
|
|
`<button class="srow" data-acct="${i}">${avatar(a.l, a.t, 36, i === current)}` +
|
|
`<span class="srow-main"><b>${a.u}</b><i>${a.h}</i></span>${i === current ? `<span class="pill-tag">Active</span>` : ""}</button>`
|
|
).join("") +
|
|
`<div class="srow srow-accent">${ic("userPlus", 20)}<span class="srow-main"><b>Add account</b></span></div>`;
|
|
};
|
|
acctHost.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-acct]");
|
|
if (!b) return;
|
|
current = Number(b.dataset.acct);
|
|
draw();
|
|
const note = $("#acctNote");
|
|
if (note) note.textContent = `Now browsing ${accounts[current].h}. Each account keeps its own sync, cache and favorites.`;
|
|
});
|
|
draw();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Locks
|
|
--------------------------------------------------------------------- */
|
|
|
|
const lockHost = $("#lockDemo");
|
|
if (lockHost) {
|
|
const L = { login: true, switching: true, hidden: false };
|
|
const rows = [
|
|
["login", "lock", "Lock with fingerprint", "Require this device's PIN or biometric to open Noo", false],
|
|
["switching", "switch", "Lock account switching", "Unlock to switch between saved accounts", true],
|
|
["hidden", "hidden", "Lock hidden files", "Unlock to reveal hidden files and folders", true],
|
|
];
|
|
const draw = () => {
|
|
lockHost.innerHTML = rows.map(([k, icon, label, sub, dep]) => {
|
|
const off = dep && !L.login;
|
|
return `<div class="srow${off ? " is-disabled" : ""}"><span class="srow-ic">${ic(icon, 20)}</span>` +
|
|
`<span class="srow-main"><b>${label}</b><i>${sub}</i></span>` +
|
|
`<button class="toggle${L[k] ? " is-on" : ""}" role="switch" aria-checked="${L[k]}" aria-label="${label}" data-lock="${k}" ${off ? "disabled" : ""}><i></i></button></div>`;
|
|
}).join("");
|
|
};
|
|
lockHost.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-lock]");
|
|
if (!b || b.disabled) return;
|
|
L[b.dataset.lock] = !L[b.dataset.lock];
|
|
draw();
|
|
});
|
|
draw();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Sync
|
|
--------------------------------------------------------------------- */
|
|
|
|
const syncHost = $("#syncDemo");
|
|
if (syncHost) {
|
|
const Y = { all: false, notif: true, cell: false, busy: false, done: false };
|
|
const draw = () => {
|
|
const tog = (k, on) => `<button class="toggle${on ? " is-on" : ""}" role="switch" aria-checked="${on}" data-sync="${k}" aria-label="${k}"><i></i></button>`;
|
|
syncHost.innerHTML =
|
|
`<div class="srow"><span class="srow-ic">${ic("cloud", 20)}</span><span class="srow-main"><b>Sync everything</b><i>Mirror the whole account instead of picking folders</i></span>${tog("all", Y.all)}</div>` +
|
|
`<div class="srow"><span class="srow-ic">${ic("offline", 20)}</span><span class="srow-main"><b>View offline files</b><i>${Y.all ? "Every folder in this account is synced" : "3 synced folders/files"}</i></span>${ic("chevR", 18, "b-muted")}</div>` +
|
|
`<div class="srow"><span class="srow-ic">${ic("bell", 20)}</span><span class="srow-main"><b>Background sync notifications</b><i>Show a notification when a background sync updates files. Conflicts and "Sync now" always notify.</i></span>${tog("notif", Y.notif)}</div>` +
|
|
`<div class="srow"><span class="srow-ic">${ic("signal", 20)}</span><span class="srow-main"><b>Sync on cellular</b><i>Off = background sync only runs on Wi-Fi</i></span>${tog("cell", Y.cell)}</div>` +
|
|
`<div class="srow"><span class="srow-ic">${ic("refresh", 20)}</span><span class="srow-main"><b>Sync now</b>${Y.done ? `<i>Up to date - just now</i>` : ""}</span>` +
|
|
(Y.busy ? `<span class="spinner" aria-label="Syncing"></span>` : `<button class="btn-tonal" data-sync="now">Sync now</button>`) + `</div>`;
|
|
};
|
|
syncHost.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-sync]");
|
|
if (!b) return;
|
|
const k = b.dataset.sync;
|
|
if (k === "now") {
|
|
Y.busy = true; Y.done = false; draw();
|
|
setTimeout(() => { Y.busy = false; Y.done = true; draw(); }, 1400);
|
|
} else {
|
|
Y[k] = !Y[k];
|
|
draw();
|
|
}
|
|
});
|
|
draw();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Share sheet
|
|
--------------------------------------------------------------------- */
|
|
|
|
const shareHost = $("#shareDemo");
|
|
if (shareHost) {
|
|
const people = [
|
|
{ n: "ayushya", sub: "cloud.example.com", t: "#DA9AC7", l: "A", owner: true },
|
|
{ n: "Ana Souza", sub: "ana@example.com", t: "#8DCDE2", l: "AS", perm: "Can edit" },
|
|
{ n: "Leo Park", sub: "Design team", t: "#A7D296", l: "LP", perm: "Can view" },
|
|
];
|
|
const SH = { link: true, linkPerm: "Can view", copied: false, menu: null, query: "" };
|
|
const draw = () => {
|
|
shareHost.innerHTML =
|
|
`<span class="sheet-grab"></span><div class="sheet-title"><b>Q3 Planning.pdf</b><i>2.4 MB</i></div>` +
|
|
`<section class="sh-sec"><div class="sh-head"><h5>Share with people</h5></div>` +
|
|
`<label class="field">${ic("search", 18)}<input type="text" placeholder="Name, group or email" aria-label="Search people" value="${esc(SH.query)}"></label>` +
|
|
people.map((p, i) =>
|
|
`<div class="prow">${avatar(p.l, p.t, 36)}<span class="srow-main"><b>${p.n}</b><i>${p.sub}</i></span>` +
|
|
(p.owner ? `<span class="owner">Owner</span>` : `<span class="menu-wrap"><button class="pill" data-perm="${i}">${p.perm}${ic("chevD", 14)}</button>` +
|
|
(SH.menu === i ? `<span class="mini-menu">${["Can view", "Can edit"].map((o) => `<button data-set="${i}|${o}">${o}${o === p.perm ? ic("check", 14) : ""}</button>`).join("")}<button class="danger" data-set="${i}|remove">Remove access</button></span>` : "") + `</span>`) + `</div>`
|
|
).join("") + `</section>` +
|
|
`<section class="sh-sec"><div class="sh-head"><h5>Share link</h5><button class="toggle${SH.link ? " is-on" : ""}" role="switch" aria-checked="${SH.link}" data-link aria-label="Share link"><i></i></button></div>` +
|
|
(SH.link
|
|
? `<div class="prow"><span class="srow-ic">${ic("link", 20)}</span><span class="srow-main"><b>Public link</b><i>cloud.example.com/s/q3-plan</i></span><button class="pill" data-linkperm>${SH.linkPerm}${ic("chevD", 14)}</button></div>` +
|
|
`<button class="btn-primary-sm" data-copy>${ic("copy", 16)}${SH.copied ? "Copied" : "Copy link"}</button>`
|
|
: `<p class="sh-cap">Turn on to create a public link.</p>`) + `</section>` +
|
|
`<section class="sh-sec"><div class="sh-head"><h5>Send file directly</h5></div><button class="btn-tonal btn-wide" data-send>${ic("send", 16)}Send via another app</button>` +
|
|
`<p class="sh-cap">Link settings above don't apply - this sends the file's bytes directly.</p></section>`;
|
|
};
|
|
shareHost.addEventListener("click", (e) => {
|
|
const t = e.target;
|
|
let b;
|
|
if ((b = t.closest("[data-perm]"))) {
|
|
const i = Number(b.dataset.perm);
|
|
SH.menu = SH.menu === i ? null : i;
|
|
} else if ((b = t.closest("[data-set]"))) {
|
|
const [i, v] = b.dataset.set.split("|");
|
|
if (v === "remove") people.splice(Number(i), 1);
|
|
else people[Number(i)].perm = v;
|
|
SH.menu = null;
|
|
} else if (t.closest("[data-link]")) {
|
|
SH.link = !SH.link;
|
|
SH.copied = false;
|
|
} else if (t.closest("[data-linkperm]")) {
|
|
SH.linkPerm = SH.linkPerm === "Can view" ? "Can edit" : "Can view";
|
|
} else if (t.closest("[data-copy]")) {
|
|
SH.copied = true;
|
|
setTimeout(() => { SH.copied = false; draw(); }, 1600);
|
|
} else if (!t.closest(".mini-menu")) {
|
|
if (SH.menu === null) return;
|
|
SH.menu = null;
|
|
} else return;
|
|
draw();
|
|
});
|
|
shareHost.addEventListener("input", (e) => { SH.query = e.target.value; });
|
|
draw();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Action bar
|
|
--------------------------------------------------------------------- */
|
|
|
|
const ACTIONS = {
|
|
favorite: ["Favorite", "star"], share: ["Share", "share"], download: ["Download", "download"],
|
|
delete: ["Delete", "trash"], copy: ["Copy", "copy"], move: ["Move", "move"],
|
|
rename: ["Rename", "rename"], sync: ["Sync to device", "offline"], details: ["Details", "info"],
|
|
};
|
|
|
|
const actionHost = $("#actionDemo");
|
|
if (actionHost) {
|
|
let order = Object.keys(ACTIONS);
|
|
let more = false;
|
|
const INLINE = 3;
|
|
const orderList = $("#actionOrder");
|
|
const bar = $("#actionBar");
|
|
|
|
const drawBar = () => {
|
|
const inline = order.slice(0, INLINE);
|
|
const rest = order.slice(INLINE);
|
|
bar.innerHTML =
|
|
`<div class="selbar"><span class="selbar-x">${ic("x", 18)}</span><b>2 selected</b>` +
|
|
`<span class="selbar-acts">${inline.map((k) => `<button class="selbar-btn" aria-label="${ACTIONS[k][0]}" title="${ACTIONS[k][0]}">${ic(ACTIONS[k][1], 20)}</button>`).join("")}` +
|
|
`<button class="selbar-btn" data-more aria-label="More" aria-expanded="${more}">${ic("moreH", 20)}</button></span></div>` +
|
|
(more ? `<div class="sheet-mini" role="menu"><span class="sheet-grab"></span>${rest.map((k) => `<div class="srow">${`<span class="srow-ic">${ic(ACTIONS[k][1], 20)}</span>`}<span class="srow-main"><b>${ACTIONS[k][0]}</b></span></div>`).join("")}</div>` : "");
|
|
};
|
|
const drawList = () => {
|
|
orderList.innerHTML = order.map((k, i) =>
|
|
`<li class="tab-row" data-id="${k}"><span class="grip" data-grip aria-label="Drag to reorder">${ic("grip", 18)}</span>` +
|
|
`<span class="tab-ic">${ic(ACTIONS[k][1], 20)}</span><span class="tab-label">${ACTIONS[k][0]}</span>` +
|
|
`<span class="where ${i < INLINE ? "is-bar" : ""}">${i < INLINE ? "In bar" : "More"}</span></li>`
|
|
).join("");
|
|
};
|
|
sortable(orderList, (ids) => { order = ids; drawList(); drawBar(); });
|
|
bar.addEventListener("click", (e) => {
|
|
if (e.target.closest("[data-more]")) { more = !more; drawBar(); }
|
|
});
|
|
drawList();
|
|
drawBar();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Swiping
|
|
--------------------------------------------------------------------- */
|
|
|
|
const swipeHost = $("#swipeDemo");
|
|
if (swipeHost) {
|
|
const BLOCK = 96, THROUGH = 1.8;
|
|
const cfg = { right: "favorite", left: "delete" };
|
|
const files = [ROWS.files[3], ROWS.files[4], ROWS.files[6], ROWS.files[7]].map((r) => ({ r, fav: r[3] && r[3].includes("fav") }));
|
|
const toast = $("#swipeToast");
|
|
let toastTimer;
|
|
const say = (msg) => {
|
|
toast.textContent = msg;
|
|
toast.classList.add("is-on");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => toast.classList.remove("is-on"), 1800);
|
|
};
|
|
const meta = {
|
|
none: null,
|
|
favorite: ["Favorite", "star", "ac"],
|
|
delete: ["Delete", "trash", "dgf"],
|
|
share: ["Share", "share", "ac"],
|
|
};
|
|
const list = $("#swipeList");
|
|
|
|
const drawRows = () => {
|
|
list.innerHTML = files.map((f, i) => {
|
|
const row = fileRow([f.r[0], f.r[1], f.r[2], (f.fav ? "fav " : "") + (f.r[3] || "").replace("fav", "")]);
|
|
const blk = (side) => {
|
|
const m = meta[cfg[side]];
|
|
if (!m) return "";
|
|
return `<div class="sw-block sw-${side}" style="background:var(--${m[2]})" data-act-side="${side}"><span class="sw-inner">${ic(m[1], 20)}<b>${f.fav && cfg[side] === "favorite" ? "Unfavorite" : m[0]}</b></span></div>`;
|
|
};
|
|
return `<div class="sw" data-i="${i}">${blk("right")}${blk("left")}<div class="sw-fg">${row}</div></div>`;
|
|
}).join("");
|
|
};
|
|
|
|
const trigger = (i, side) => {
|
|
const a = cfg[side];
|
|
const f = files[i];
|
|
if (a === "favorite") { f.fav = !f.fav; say(f.fav ? `Added ${f.r[0]} to favorites` : `Removed ${f.r[0]} from favorites`); drawRows(); }
|
|
else if (a === "delete") {
|
|
const el = $(`.sw[data-i="${i}"]`, list);
|
|
el.classList.add("is-gone");
|
|
say(`Moved ${f.r[0]} to trash (demo - it comes back)`);
|
|
setTimeout(drawRows, 1500);
|
|
} else if (a === "share") { say(`Share sheet opens for ${f.r[0]}`); drawRows(); }
|
|
};
|
|
|
|
let drag = null;
|
|
list.addEventListener("pointerdown", (e) => {
|
|
const sw = e.target.closest(".sw");
|
|
if (!sw) return;
|
|
const blk = e.target.closest("[data-act-side]");
|
|
if (blk && sw.dataset.open === blk.dataset.actSide) { trigger(Number(sw.dataset.i), blk.dataset.actSide); return; }
|
|
const fg = $(".sw-fg", sw);
|
|
const base = Number(sw.dataset.dx || 0);
|
|
drag = { sw, fg, x0: e.clientX, base, moved: false, id: e.pointerId };
|
|
sw.setPointerCapture?.(e.pointerId);
|
|
});
|
|
list.addEventListener("pointermove", (e) => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
let dx = drag.base + (e.clientX - drag.x0);
|
|
if (Math.abs(e.clientX - drag.x0) > 4) drag.moved = true;
|
|
const maxRight = cfg.right === "none" ? 0 : BLOCK * 2.2;
|
|
const maxLeft = cfg.left === "none" ? 0 : BLOCK * 2.2;
|
|
dx = Math.max(-maxLeft, Math.min(maxRight, dx));
|
|
drag.dx = dx;
|
|
drag.sw.dataset.dx = dx;
|
|
drag.sw.classList.add("is-dragging");
|
|
drag.sw.classList.toggle("show-right", dx > 0);
|
|
drag.sw.classList.toggle("show-left", dx < 0);
|
|
drag.sw.style.setProperty("--dx", dx + "px");
|
|
drag.sw.classList.toggle("is-through", Math.abs(dx) >= BLOCK * THROUGH);
|
|
});
|
|
const end = (e) => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
const { sw } = drag;
|
|
const dx = drag.dx ?? drag.base;
|
|
const side = dx > 0 ? "right" : "left";
|
|
sw.classList.remove("is-dragging", "is-through");
|
|
let target = 0;
|
|
if (Math.abs(dx) >= BLOCK * THROUGH) {
|
|
trigger(Number(sw.dataset.i), side);
|
|
drag = null;
|
|
return;
|
|
}
|
|
if (Math.abs(dx) > BLOCK / 2) target = Math.sign(dx) * BLOCK;
|
|
sw.dataset.dx = target;
|
|
sw.dataset.open = target === 0 ? "" : side;
|
|
sw.style.setProperty("--dx", target + "px");
|
|
if (target === 0) sw.classList.remove("show-right", "show-left");
|
|
drag = null;
|
|
};
|
|
list.addEventListener("pointerup", end);
|
|
list.addEventListener("pointercancel", end);
|
|
|
|
// Pickers
|
|
$$("[data-swipe]", swipeHost).forEach((group) => {
|
|
const side = group.dataset.swipe;
|
|
const sync = () => $$("[data-val]", group).forEach((b) => b.classList.toggle("is-active", b.dataset.val === cfg[side]));
|
|
group.addEventListener("click", (e) => {
|
|
const b = e.target.closest("[data-val]");
|
|
if (!b) return;
|
|
cfg[side] = b.dataset.val;
|
|
sync();
|
|
drawRows();
|
|
});
|
|
sync();
|
|
});
|
|
drawRows();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Page chrome: mobile nav, hero parallax, scroll reveal
|
|
--------------------------------------------------------------------- */
|
|
|
|
const navToggle = $("#navToggle");
|
|
const mobileNav = $("#mobileNav");
|
|
if (navToggle && mobileNav) {
|
|
navToggle.addEventListener("click", () => {
|
|
const open = mobileNav.classList.toggle("is-open");
|
|
navToggle.setAttribute("aria-expanded", String(open));
|
|
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
|
|
});
|
|
$$("a", mobileNav).forEach((a) =>
|
|
a.addEventListener("click", () => {
|
|
mobileNav.classList.remove("is-open");
|
|
navToggle.setAttribute("aria-expanded", "false");
|
|
})
|
|
);
|
|
}
|
|
|
|
const heroVisual = $("#heroVisual");
|
|
if (heroVisual && !reduceMotion) {
|
|
const layers = $$("[data-depth]", heroVisual).map((el) => ({ el, depth: parseFloat(el.dataset.depth) || 0.3 }));
|
|
let scrollOffset = 0;
|
|
let pointer = { x: 0, y: 0 };
|
|
let ticking = false;
|
|
|
|
const apply = () => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
|
for (const l of layers) {
|
|
l.el.style.transform = `translate3d(${pointer.x * l.depth * 14}px, ${scrollOffset * l.depth * 40 + pointer.y * l.depth * 14}px, 0)`;
|
|
}
|
|
}
|
|
ticking = false;
|
|
};
|
|
const tick = () => { if (!ticking) { ticking = true; requestAnimationFrame(apply); } };
|
|
const onScroll = () => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight;
|
|
tick();
|
|
};
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
heroVisual.addEventListener("pointermove", (e) => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
|
|
tick();
|
|
});
|
|
heroVisual.addEventListener("pointerleave", () => { pointer = { x: 0, y: 0 }; tick(); });
|
|
onScroll();
|
|
}
|
|
|
|
const revealTargets = $$(".card, .showcase-copy, .showcase-demo, .tab-chip, .privacy-points li");
|
|
revealTargets.forEach((el) => el.classList.add("reveal"));
|
|
if ("IntersectionObserver" in window && !reduceMotion) {
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add("is-visible");
|
|
io.unobserve(entry.target);
|
|
}
|
|
}
|
|
},
|
|
{ threshold: 0.12, rootMargin: "0px 0px -40px 0px" }
|
|
);
|
|
revealTargets.forEach((el) => io.observe(el));
|
|
} else {
|
|
revealTargets.forEach((el) => el.classList.add("is-visible"));
|
|
}
|
|
})();
|