aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-27 10:09:16 -0800
committerAdam Soltys <[email protected]>2026-02-27 10:09:16 -0800
commitbf7d8d373c11cd7a6e1487175147c9a84e775a98 (patch)
tree82fb58568de073ff03dfca3d55d8ce5adcf51608 /src
parent2aa2ac29eb241fc8ded3671e728e578e72652056 (diff)
downloadcoinos-ui-bf7d8d373c11cd7a6e1487175147c9a84e775a98.tar.gz
coinos-ui-bf7d8d373c11cd7a6e1487175147c9a84e775a98.zip
map updates
Diffstat (limited to 'src')
-rw-r--r--src/components/About.svelte2
-rw-r--r--src/components/Footer.svelte25
-rw-r--r--src/components/LandingHero.svelte4
-rw-r--r--src/components/LocationsMap.svelte168
-rw-r--r--src/locales/en.json2
-rw-r--r--src/routes/(app)/locations/+server.ts16
-rw-r--r--src/routes/(app)/settings/security/+page.svelte35
-rw-r--r--src/routes/(static)/register/+page.server.ts18
-rw-r--r--src/routes/(static)/register/+page.svelte62
-rw-r--r--src/routes/+page.svelte2
-rw-r--r--src/routes/map/+page.server.ts10
-rw-r--r--src/routes/map/+page.svelte5
12 files changed, 238 insertions, 111 deletions
diff --git a/src/components/About.svelte b/src/components/About.svelte
index 874ec8db..b2f376ed 100644
--- a/src/components/About.svelte
+++ b/src/components/About.svelte
@@ -60,7 +60,7 @@
</div>
</div>
-<div>
+<div class="mb-20">
<h3 class="text-5xl font-medium mb-5 text-center">{$t("about.locations.header")}</h3>
<p class="text-secondary text-xl w-full md:w-10/12 lg:w-2/3 2xl:w-[800px] mx-auto mb-10">
{@html $t("about.locations.description")}
diff --git a/src/components/Footer.svelte b/src/components/Footer.svelte
index 8e80151c..0bdee16e 100644
--- a/src/components/Footer.svelte
+++ b/src/components/Footer.svelte
@@ -9,7 +9,6 @@
titleID: "nostr",
},
{ url: "https://t.me/coinoswallet", titleID: "telegram" },
- { url: "https://linktr.ee/coinos.io", titleID: "linktree" },
];
const companyLinks = [
@@ -20,21 +19,18 @@
<footer class="space-y-10 md:space-y-0 text-xl block md:flex justify-around pb-8 px-4">
<div class="space-y-5">
- <div class="text-center w-full">
+ <div class="text-center w-full hidden md:block">
<a href="/" aria-label="Home">
<iconify-icon noobserver icon="coinos:logo" width="224"></iconify-icon>
</a>
</div>
- <div class="flex gap-2 justify-center sm:hidden">
- <div>
- <a href="/register">
- <button class="btn">{$t("nav.startInSeconds")}</button>
- </a>
- </div>
+ <div class="w-full sm:hidden">
+ <a href="/register">
+ <button class="btn btn-accent w-full text-2xl">{$t("nav.startInSeconds")}</button>
+ </a>
</div>
- <div class="w-full flex flex-wrap sm:flex-nowrap gap-2 justify-center items-center">
+ <div class="w-full hidden sm:flex gap-2 justify-center items-center">
<DarkToggle />
- <LocaleSelector />
</div>
</div>
@@ -62,6 +58,11 @@
</ul>
</div>
- <iconify-icon noobserver icon="coinos:shield" class="flex justify-center" width="96"
- ></iconify-icon>
+ <div class="space-y-5">
+ <div class="flex justify-center">
+ <LocaleSelector />
+ </div>
+ <iconify-icon noobserver icon="coinos:shield" class="flex justify-center" width="96"
+ ></iconify-icon>
+ </div>
</footer>
diff --git a/src/components/LandingHero.svelte b/src/components/LandingHero.svelte
index dae0bf2b..44def229 100644
--- a/src/components/LandingHero.svelte
+++ b/src/components/LandingHero.svelte
@@ -27,11 +27,11 @@
{$t("landing.subheader")}
</h2>
- <div class="flex justify-center">
+ <div>
<a href="/register">
<button
use:focus
- class="btn !rounded-full bg-primary text-primary-content hover:bg-primary/80"
+ class="btn !rounded-full bg-primary text-primary-content hover:bg-primary/80 w-full text-2xl"
>
{$t("nav.startInSeconds")}
</button>
diff --git a/src/components/LocationsMap.svelte b/src/components/LocationsMap.svelte
index 185a6ae5..dbc56375 100644
--- a/src/components/LocationsMap.svelte
+++ b/src/components/LocationsMap.svelte
@@ -4,11 +4,12 @@
import Popup from "$comp/Popup.svelte";
import { back } from "$lib/utils";
- let { locations } = $props();
let map: any;
+ let maplibreRef: any;
+ let mapReady = $state(false);
let mapContainer: any = $state(),
mapWrapper: any = $state();
- let markers: any[] = [];
+ let markers: any[] = $state([]);
let search: string = $state("");
let clearSearch = (e: any) => (search = "");
@@ -20,13 +21,10 @@
if (mapWrapper.requestFullscreen) {
mapWrapper.requestFullscreen();
} else if (mapWrapper.mozRequestFullScreen) {
- /* Firefox */
mapWrapper.mozRequestFullScreen();
} else if (mapWrapper.webkitRequestFullscreen) {
- /* Chrome, Safari & Opera */
mapWrapper.webkitRequestFullscreen();
} else if (mapWrapper.msRequestFullscreen) {
- /* IE/Edge */
mapWrapper.msRequestFullscreen();
}
@@ -36,13 +34,10 @@
if (doc.exitFullscreen) {
doc.exitFullscreen();
} else if (doc.mozCancelFullScreen) {
- /* Firefox */
doc.mozCancelFullScreen();
} else if (doc.webkitExitFullscreen) {
- /* Chrome, Safari and Opera */
doc.webkitExitFullscreen();
} else if (doc.msExitFullscreen) {
- /* IE/Edge */
doc.msExitFullscreen();
}
}
@@ -84,16 +79,6 @@
await new Promise((r) => setTimeout(r, 2500));
if (!timeout) return;
- //
- // map.flyTo({
- // zoom: 10.5,
- // center: marker.getLngLat(),
- // essential: true,
- // easing: function (t) {
- // return 1 - Math.pow(1 - t, 3);
- // },
- // duration: 1000,
- // });
}
let toggle = () => {
@@ -159,8 +144,6 @@
for (let marker of markers) {
let isInView = bounds.contains(marker.getLngLat());
- let { tags } = marker;
-
if (isInView) {
inview.push(marker);
}
@@ -170,10 +153,98 @@
}, 200);
let locationMarkers: any = {};
+ let addedIds = new Set();
let popups: any = {};
let counter = 0;
let maplibrePromise: any;
+ function addMarkers(locations: any[]) {
+ let maplibre = maplibreRef;
+ if (!map || !maplibre) return;
+
+ locations.forEach((location) => {
+ if (location["deleted_at"]) return;
+ if (addedIds.has(location.id)) return;
+ addedIds.add(location.id);
+
+ let { lat, lon, tags } = location["osm_json"];
+ if (!(lat && lon)) return;
+
+ let popupContainer = document.createElement("div");
+
+ mount(Popup, {
+ target: popupContainer,
+ props: { tags },
+ });
+
+ let marker = new maplibre.Marker({ color: "#F7931A", scale: 0.65 })
+ .setLngLat([lon, lat])
+ .setPopup(new maplibre.Popup().setDOMContent(popupContainer))
+ .addTo(map);
+
+ marker.id = counter++;
+
+ marker.getElement().addEventListener("click", () => {
+ let p = marker.getPopup();
+ popups[marker.id] = p;
+ p.setDOMContent(popupContainer);
+ setTimeout(() => {
+ selected = marker;
+ setTimeout(scroll, 10);
+ }, 50);
+ p.on("close", () => {
+ selected = undefined;
+ delete popups[marker.id];
+ });
+ });
+
+ marker.tags = tags;
+ locationMarkers[location.id] = marker;
+
+ markers.push(marker);
+ });
+
+ updateLabelVisibility();
+ }
+
+ let fetchController: AbortController | null = null;
+
+ async function fetchNearby(lat: number, lon: number, radius: number) {
+ if (fetchController) fetchController.abort();
+ fetchController = new AbortController();
+
+ try {
+ let params = new URLSearchParams({
+ lat: String(lat),
+ lon: String(lon),
+ radius: String(Math.ceil(radius)),
+ count: "100",
+ });
+ let res = await fetch(`/locations?${params}`, { signal: fetchController.signal });
+ let data = await res.json();
+ if (data.locations) addMarkers(data.locations);
+ } catch (e: any) {
+ if (e.name !== "AbortError") console.log("fetch nearby failed", e);
+ }
+ }
+
+ function getViewportRadius() {
+ let bounds = map.getBounds();
+ let center = map.getCenter();
+ let ne = bounds.getNorthEast();
+
+ let dlat = ne.lat - center.lat;
+ let dlng = ne.lng - center.lng;
+ let km = Math.sqrt(dlat * dlat + dlng * dlng) * 111;
+ return Math.max(km, 10);
+ }
+
+ let fetchOnMove = debounce(() => {
+ if (!map) return;
+ let center = map.getCenter();
+ fetchNearby(center.lat, center.lng, getViewportRadius());
+ }, 500);
+
const loadMaplibre = async () => {
if ((window as any).maplibregl) return (window as any).maplibregl;
if (maplibrePromise) return maplibrePromise;
@@ -210,6 +281,8 @@
await tick();
if (!mapContainer) return;
loadMaplibre().then((maplibre) => {
+ maplibreRef = maplibre;
+
map = new maplibre.Map({
container: mapContainer,
style: "https://tiles.stadiamaps.com/styles/stamen_toner.json",
@@ -217,51 +290,26 @@
zoom: 10.5,
});
- map.on("load", () => {
- locations.forEach((location) => {
- if (location["deleted_at"]) return;
-
- let { lat, lon, tags } = location["osm_json"];
- if (!(lat && lon)) return;
-
- let element = document.createElement("div");
- element.className = "marker";
-
- let popupContainer = document.createElement("div");
+ map.scrollZoom.setWheelZoomRate(1 / 60);
+ map.scrollZoom.setZoomRate(1 / 30);
- mount(Popup, {
- target: popupContainer,
- props: { tags },
- });
+ map.on("load", () => {
+ mapReady = true;
- let marker = new maplibre.Marker({ color: "#F7931A", scale: 0.65 })
- .setLngLat([lon, lat])
- .setPopup(new maplibre.Popup().setDOMContent(popupContainer))
- .addTo(map);
+ // Initial fetch for default view
+ fetchNearby(49.26, -123.05, 50);
- marker.id = counter++;
+ // Fetch more on pan/zoom
+ map.on("moveend", fetchOnMove);
- marker.getElement().addEventListener("click", () => {
- let p = marker.getPopup();
- popups[marker.id] = p;
- p.setDOMContent(popupContainer);
- setTimeout(() => {
- selected = marker;
- setTimeout(scroll, 10);
- }, 50);
- p.on("close", () => {
- selected = undefined;
- delete popups[marker.id];
- });
+ // Center on user if available
+ if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition((position) => {
+ let { longitude: lng, latitude: lat } = position.coords;
+ map.flyTo({ center: [lng, lat], zoom: 11, duration: 1000 });
+ fetchNearby(lat, lng, 50);
});
-
- marker.tags = tags;
- locationMarkers[location.id] = marker;
-
- markers.push(marker);
- });
-
- updateLabelVisibility();
+ }
});
map.on("mousedown", stopFlying);
diff --git a/src/locales/en.json b/src/locales/en.json
index 196c55df..3d76e0ea 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -288,7 +288,7 @@
"profile": "Profile",
"register": "Register",
"account": "Account",
- "startInSeconds": "Start in Seconds",
+ "startInSeconds": "Start Now",
"signIn": "Sign In",
"signOut": "Sign out",
"dashboard": "Dashboard",
diff --git a/src/routes/(app)/locations/+server.ts b/src/routes/(app)/locations/+server.ts
index 9980c5fc..ecbb234b 100644
--- a/src/routes/(app)/locations/+server.ts
+++ b/src/routes/(app)/locations/+server.ts
@@ -1,11 +1,23 @@
import { get } from "$lib/utils";
import { json } from "@sveltejs/kit";
-export async function GET({ setHeaders }) {
+export async function GET({ setHeaders, url }) {
+ const lat = url.searchParams.get("lat");
+ const lon = url.searchParams.get("lon");
+ const radius = url.searchParams.get("radius");
+ const count = url.searchParams.get("count");
+
let locations = [];
try {
- ({ locations } = await get("/locations"));
+ if (lat && lon) {
+ const params = new URLSearchParams({ lat, lon });
+ if (radius) params.set("radius", radius);
+ if (count) params.set("count", count);
+ ({ locations } = await get(`/locations/nearby?${params}`));
+ } else {
+ ({ locations } = await get("/locations"));
+ }
setHeaders({ "cache-control": "public, max-age=600" });
} catch {
console.log("failed to fetch locations");
diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte
index 33981645..0991c679 100644
--- a/src/routes/(app)/settings/security/+page.svelte
+++ b/src/routes/(app)/settings/security/+page.svelte
@@ -4,9 +4,12 @@
import { t } from "$lib/translations";
import Pin from "$comp/Pin.svelte";
import Qr from "$comp/Qr.svelte";
+ import Spinner from "$comp/Spinner.svelte";
import { copy, post, success, fail } from "$lib/utils";
import { save, pin as current } from "$lib/store";
import { getNsec } from "$lib/nostr";
+ import { registerPasskey } from "$lib/passkey";
+ import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { invalidate } from "$app/navigation";
import { page } from "$app/stores";
@@ -147,6 +150,22 @@
fail("Failed to disable 2FA, try again");
}
};
+ let passkeyLoading = $state(false);
+
+ let addPasskey = async () => {
+ passkeyLoading = true;
+ try {
+ const prfKey = await registerPasskey();
+ rememberPrfKey(prfKey, defaultRememberForMs);
+ success("Passkey added");
+ } catch (e: any) {
+ if (e.name !== "NotAllowedError") {
+ fail(e.message || "Passkey registration failed");
+ }
+ }
+ passkeyLoading = false;
+ };
+
let verifying = $derived(pin?.length > 5);
$effect(() => {
@@ -220,10 +239,14 @@
</div>
<div>
- <a href={`/account/seed`} class="contents">
- <button class="btn btn-lg w-full rounded-2xl whitespace-nowrap">
- <iconify-icon noobserver icon="ph:plus-circle-bold" width="32"></iconify-icon>
- {$t("accounts.addAccount")}
- </button>
- </a>
+ <span class="font-bold mb-1">Passkey</span>
+ <p class="text-secondary mb-1">Add a passkey for passwordless sign-in</p>
+ <button type="button" class="btn" onclick={addPasskey} disabled={passkeyLoading}>
+ {#if passkeyLoading}
+ <Spinner />
+ {:else}
+ <iconify-icon noobserver icon="ph:fingerprint-bold" width="32"></iconify-icon>
+ {/if}
+ Add Passkey
+ </button>
</div>
diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts
index 5df89541..55331c5a 100644
--- a/src/routes/(static)/register/+page.server.ts
+++ b/src/routes/(static)/register/+page.server.ts
@@ -57,6 +57,24 @@ export const actions = {
redirect(303, loginRedirect || `/${username}`);
},
+ passkeyCreate: async ({ request }) => {
+ const ip = request.headers.get("cf-connecting-ip") ?? "";
+ const form = await fd(request);
+ const { username, password, challenge, recaptcha, picture } = form;
+
+ const user = { picture, username, password, challenge, recaptcha };
+ const headers: Record<string, string> = {};
+ if (ip) headers["cf-connecting-ip"] = ip;
+
+ try {
+ const { sk, token } = await post("/register", { user }, headers);
+ return { token, username, sk };
+ } catch (e) {
+ const { message } = e as Error;
+ return fail(400, { error: message });
+ }
+ },
+
activate: async ({ cookies, request }) => {
const form = await fd(request);
const { token, username, sk, pubkey } = form;
diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte
index 689863e5..e2d53fd0 100644
--- a/src/routes/(static)/register/+page.svelte
+++ b/src/routes/(static)/register/+page.svelte
@@ -15,7 +15,7 @@
import Spinner from "$comp/Spinner.svelte";
import { focus, fail, versions, post } from "$lib/utils";
- import { registerPasskey, loginWithPasskey } from "$lib/passkey";
+ import { registerPasskey } from "$lib/passkey";
import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
import { t } from "$lib/translations";
@@ -186,28 +186,64 @@
let passkeyRegister = async () => {
passkeyLoading = true;
try {
- const { credential, challengeId, prfKey } = await loginWithPasskey();
- const loginData = new FormData();
- loginData.append("credential", JSON.stringify(credential));
- loginData.append("challengeId", challengeId);
- loginData.append("loginRedirect", (redirect ?? "") as string);
+ let name =
+ username ||
+ uniqueNamesGenerator({
+ dictionaries: [animals, NumberDictionary.generate({ min: 10, max: 99 })],
+ length: 2,
+ separator: "",
+ });
+
+ let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
+ b.toString(16).padStart(2, "0"),
+ ).join("");
+
+ const recaptcha = await getRecaptchaToken();
+
+ const formData = new FormData();
+ formData.append("username", name);
+ formData.append("password", pw);
+ formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha as string);
+ formData.append("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
+
+ const response = await fetch("/register?/passkeyCreate", {
+ method: "POST",
+ body: formData,
+ });
+
+ const result: any = deserialize(await response.text());
+ if (result.type !== "success") {
+ fail(result.data?.error || "Account creation failed");
+ passkeyLoading = false;
+ return;
+ }
+
+ const { token, sk } = result.data;
+ const prfKey = await registerPasskey(token);
+
+ const activateData = new FormData();
+ activateData.append("token", token);
+ activateData.append("username", name);
+ if (sk) activateData.append("sk", sk);
+ activateData.append("loginRedirect", (redirect ?? "") as string);
- const loginResponse = await fetch("/login?/passkey", {
+ const activateResponse = await fetch("/register?/activate", {
method: "POST",
- body: loginData,
+ body: activateData,
});
- const loginResult = deserialize(await loginResponse.text());
+ const activateResult = deserialize(await activateResponse.text());
- if (loginResult.type === "success" || loginResult.type === "redirect") {
+ if (activateResult.type === "success" || activateResult.type === "redirect") {
rememberPrfKey(prfKey, defaultRememberForMs);
await invalidateAll();
}
- applyAction(loginResult);
+ applyAction(activateResult);
} catch (e: any) {
if (e.name !== "NotAllowedError") {
- fail(e.message || "Passkey login failed");
+ fail(e.message || "Passkey registration failed");
}
}
passkeyLoading = false;
@@ -394,7 +430,7 @@
{:else}
<iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon>
{/if}
- <div class="my-auto">Sign in with Passkey</div>
+ <div class="my-auto">Register with Passkey</div>
</button>
<button type="button" class="btn" onclick={nostrLogin}>
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 85cbe75b..2f4c7dda 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -26,7 +26,7 @@
<LandingHeader {howItWorks} {faq} {about} {user} />
-<main class="space-y-40 py-20 md:py-32 lg:py-36 xl:py-40 px-5 md:px-0 pt-20">
+<main class="space-y-20 md:space-y-40 py-20 md:py-32 lg:py-36 xl:py-40 px-5 md:px-0 pt-20">
<LandingHero />
<LandingInfoCard
image="lightning-qr"
diff --git a/src/routes/map/+page.server.ts b/src/routes/map/+page.server.ts
index c3b14d0f..7b039895 100644
--- a/src/routes/map/+page.server.ts
+++ b/src/routes/map/+page.server.ts
@@ -1,11 +1,3 @@
-import { get } from "$lib/utils";
-
export const load = async () => {
- let data = { locations: [] };
-
- try {
- data = await get("/locations");
- } catch {}
-
- return data;
+ return {};
};
diff --git a/src/routes/map/+page.svelte b/src/routes/map/+page.svelte
index 49953479..83a8f98a 100644
--- a/src/routes/map/+page.svelte
+++ b/src/routes/map/+page.svelte
@@ -1,10 +1,7 @@
<script>
import LocationsMap from "$comp/LocationsMap.svelte";
- let { data } = $props();
-
- let locations = $derived(data.locations);
</script>
<div class="h-screen w-screen">
- <LocationsMap {locations} />
+ <LocationsMap />
</div>