Refonte mini-player flottant, nettoyage GrateWizard, corrections UI

- PlayerPersistent: widget compact pill + panneau extensible, aligné au contenu
- BookPlayer: ajustements scroll mode, suppression bordures boutons
- UnoCSS: ajout border-none au shortcut btn-ghost
- GrateWizard: suppression composants, services et utils obsolètes
- Ajout du PDF source des paroles (media/)
- Mises à jour config et dépendances

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-22 22:43:41 +01:00
parent 0e1e704319
commit ac2b8040b1
25 changed files with 232 additions and 2268 deletions

View File

@@ -20,7 +20,7 @@ export default defineAppConfig({
], ],
}, },
gratewizard: { gratewizard: {
url: '/gratewizard?popup', url: 'https://gratewizard.ml',
popup: { popup: {
width: 420, width: 420,
height: 720, height: 720,

View File

@@ -564,6 +564,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
width: 100%; width: 100%;
flex: 1; flex: 1;
min-height: 0;
} }
/* ═══════════════════════════════════════ /* ═══════════════════════════════════════
@@ -574,6 +575,7 @@ onUnmounted(() => {
max-width: 52rem; max-width: 52rem;
padding: 0 1rem; padding: 0 1rem;
gap: 0; gap: 0;
min-height: 0;
} }
/* ─── Top bar ─── */ /* ─── Top bar ─── */
@@ -729,11 +731,13 @@ onUnmounted(() => {
/* ─── Scroll mode overrides ─── */ /* ─── Scroll mode overrides ─── */
.reader-viewport--scroll { .reader-viewport--scroll {
overflow-y: auto; overflow: hidden auto;
min-height: 0;
} }
.reader-columns--scroll { .reader-columns--scroll {
height: auto; height: auto;
column-fill: unset; column-fill: unset;
column-width: unset !important;
transition: none; transition: none;
} }

View File

@@ -1,194 +0,0 @@
<template>
<div class="flex flex-col gap-5 pt-4">
<div class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">Coefficient relatif &agrave; l'anciennet&eacute;</p>
<!-- My seniority -->
<div class="flex flex-col items-center gap-1 w-full max-w-80">
<label class="gw-text text-sm">Mon anciennet&eacute;</label>
<input
v-model="myDate"
type="date"
:min="Block0Date"
:max="todayDate"
class="gw-input w-full"
/>
<p :class="['gw-text text-center text-sm', { invisible: !myDate }]">
{{ getDays(myDate) || 0 }} DUs cr&eacute;&eacute;s
</p>
</div>
<!-- Role -->
<div class="flex items-center justify-center gap-2 mt-2">
<span class="gw-text text-xs text-right">offre<br />le produit ou service</span>
<label class="gw-toggle shrink-0">
<input v-model="isSeller" type="checkbox" />
<span class="gw-toggle-slider" />
</label>
<span class="gw-text text-xs">re&ccedil;oit<br />le produit ou service</span>
</div>
<!-- Other party name + add button -->
<div class="flex items-center gap-4 w-full max-w-80">
<div class="flex-1">
<label class="gw-text text-sm">Nom {{ !isSeller ? 'du receveur' : "de l'offreur" }}</label>
<input
v-model="otherName"
type="text"
maxlength="25"
class="gw-input w-full"
/>
</div>
<button
v-if="!isBaseFriend"
class="gw-icon-btn self-end mb-0.5"
:disabled="!otherName || !otherDate || isFriend"
:title="friends.some((f) => f.name === otherName) ? 'Mettre \u00e0 jour' : 'Ajouter relation'"
@click="addFriend"
>
<svg v-if="friends.some((f) => f.name === otherName)" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" /></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
</div>
<!-- Other party date -->
<div class="flex flex-col items-center gap-1 w-full max-w-80">
<label class="gw-text text-sm">
Anciennet&eacute; {{ otherName ? 'de ' + otherName : !isSeller ? 'du receveur' : "de l'offreur" }}
</label>
<input
v-model="otherDate"
type="date"
:min="Block0Date"
:max="todayDate"
class="gw-input w-full max-w-64"
/>
<p :class="['gw-text text-center text-sm', { invisible: !otherDate }]">
{{ getDays(otherDate) || 0 }} DUs cr&eacute;&eacute;s
</p>
</div>
<!-- Price -->
<div class="flex items-center gap-3 justify-center">
<label class="gw-text text-sm whitespace-nowrap">&Eacute;valuation de r&eacute;f.</label>
<div class="relative">
<input
:value="price"
type="number"
min="0"
:step="Math.ceil(Number(price) / 10 / 2) || 1"
placeholder="0.00"
class="gw-input w-40 pr-14"
@input="price = Math.min(Number(($event.target as HTMLInputElement).value), 9999).toString()"
/>
<select
v-model="currency"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent text-white/40 text-sm cursor-pointer border-0 outline-none"
@change="price = '1'"
>
<option value="DU" class="bg-surface-100">DU</option>
<option value="G1" class="bg-surface-100">&#x11E;1</option>
</select>
</div>
</div>
<!-- Discount -->
<div class="flex items-center gap-3 justify-center">
<label class="gw-text text-sm whitespace-nowrap">R&eacute;duction newbie</label>
<div class="relative">
<input
:value="discount"
type="number"
min="0"
:step="Math.ceil(Number(discount) / 10 / 2) || 1"
placeholder="0"
class="gw-input w-40 pr-8"
@input="discount = Math.min(Number(($event.target as HTMLInputElement).value), 99).toString()"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 text-sm pointer-events-none">%</span>
</div>
</div>
<!-- Corrected price -->
<p class="gw-title mt-4">
&Eacute;valuation corrig&eacute;e : {{ fr(getFinalPrice(isSeller ? myDate : otherDate)) }} {{ currencyDisplay }}
</p>
</div>
</div>
<!-- Relations table -->
<GratewizardGwRelations
:items="tableItems"
:base-friends="baseFriends"
result-label="ÉVAL."
@select="(f) => { otherName = f.name; otherDate = f.date; }"
@remove="removeFriend"
/>
</div>
</template>
<script setup lang="ts">
import { Block0Date, getDays, getRatio, dateToString, fr, type Friend, type TableFriend } from '~/utils/gratewizard';
const todayDate = dateToString(new Date());
const baseFriends: Friend[] = [
{ name: 'Bloc 0', date: Block0Date },
{ name: 'Arrivant juste', date: todayDate },
];
const price = useLocalStorage('price', '1');
const discount = useLocalStorage('discount', '0');
const myDate = useLocalStorage<string | undefined>('myDate', undefined);
const isSeller = useLocalStorage('isSeller', true);
const currency = useLocalStorage('currency', 'DU');
const otherName = useLocalStorage('otherName', '');
const otherDate = useLocalStorage<string | undefined>('otherDate', undefined);
const friends = useLocalStorage<Friend[]>('friends', []);
const currencyDisplay = computed(() => currency.value === 'G1' ? '\u011e1' : 'DU');
function getFinalPrice(date: string | undefined): number {
if ((!date && !myDate.value) || (!date && !otherDate.value)) return Number(price.value);
const ratio = date
? myDate.value && (!isSeller.value || !otherDate.value)
? getRatio(date, myDate.value)
: getRatio(date, otherDate.value)
: 1;
const d = Number(discount.value) / 100;
const p = Number(price.value);
return (1 - d) * p + d * p * ratio;
}
function addFriend() {
if (!otherDate.value || !otherName.value) return;
if (friends.value.some((f) => f.name === otherName.value)) {
friends.value = friends.value.map((f) =>
f.name === otherName.value ? { ...f, date: otherDate.value! } : f
);
} else {
friends.value = [...friends.value, { name: otherName.value, date: otherDate.value }];
}
}
function removeFriend(name: string) {
friends.value = friends.value.filter((f) => f.name !== name);
}
const isBaseFriend = computed(() =>
baseFriends.some((f) => f.name === otherName.value && f.date === otherDate.value)
);
const isFriend = computed(() =>
friends.value.some((f) => f.name === otherName.value && f.date === otherDate.value)
);
const tableItems = computed<TableFriend[]>(() => {
return baseFriends.concat(friends.value).map((friend) => ({
...friend,
displayName: friend.name.substring(0, 10),
displayDate: new Date(friend.date).toLocaleDateString('fr-FR', { dateStyle: 'short' }),
result: fr(getFinalPrice(friend.date)),
du: getDays(friend.date),
}));
});
</script>

View File

@@ -1,208 +0,0 @@
<template>
<div class="flex flex-col gap-5 pt-4">
<div class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">Coefficient relatif au solde net</p>
<!-- Buyer name + add button -->
<div class="flex items-center gap-4 w-full max-w-80">
<div class="flex-1">
<label class="gw-text text-sm">Qui re&ccedil;oit le produit ou service ?</label>
<input
v-model="otherName"
type="text"
maxlength="25"
class="gw-input w-full"
/>
</div>
<button
v-if="!isBaseFriend"
class="gw-icon-btn self-end mb-0.5"
:disabled="!otherName || !otherDate || isFriend"
:title="friends.some((f) => f.name === otherName) ? 'Mettre \u00e0 jour' : 'Ajouter relation'"
@click="addFriend"
>
<svg v-if="friends.some((f) => f.name === otherName)" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" /></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
</div>
<!-- Buyer seniority -->
<div class="flex flex-col items-center gap-1 w-full max-w-80">
<label class="gw-text text-sm">
Anciennet&eacute; {{ otherName ? 'de ' + otherName : 'du receveur' }}
</label>
<input
v-model="otherDate"
type="date"
:min="Block0Date"
:max="todayDate"
class="gw-input w-full max-w-64"
/>
<p :class="['gw-text text-center text-sm', { invisible: !otherDate }]">
{{ seniority || 0 }} DUs cr&eacute;&eacute;s
</p>
</div>
<!-- DU Balance -->
<div class="flex items-center gap-3 justify-center">
<label class="gw-text text-sm whitespace-nowrap">Solde DU{{ otherName ? ' de ' + otherName : ' du receveur' }}</label>
<div class="relative">
<input
:value="balance"
type="number"
min="0"
placeholder="0"
class="gw-input w-40 pr-10"
@input="balance = Math.max(0, Number(($event.target as HTMLInputElement).value)).toString()"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 text-sm pointer-events-none">DU</span>
</div>
</div>
<!-- Net balance -->
<p v-if="seniority > 0" class="gw-text text-center">
Solde net : {{ fr(netBalance, 0) }} DU
{{ netBalance < 0
? ' (b\u00e9n\u00e9ficie de valeurs \u2192 majoration)'
: netBalance > 0
? ' (alimente en valeurs \u2192 minoration)'
: ' (\u00e9quilibr\u00e9)' }}
</p>
<!-- Reference value -->
<div class="flex items-center gap-3 justify-center">
<label class="gw-text text-sm whitespace-nowrap">Valeur de r&eacute;f.</label>
<div class="relative">
<input
:value="refValue"
type="number"
min="0"
:step="Math.ceil(v / 10 / 2) || 1"
placeholder="0.00"
class="gw-input w-40 pr-14"
@input="refValue = Math.min(Number(($event.target as HTMLInputElement).value), 9999).toString()"
/>
<select
v-model="currency"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent text-white/40 text-sm cursor-pointer border-0 outline-none"
@change="refValue = '1'"
>
<option value="DU" class="bg-surface-100">DU</option>
<option value="G1" class="bg-surface-100">&#x11E;1</option>
</select>
</div>
</div>
<!-- Weight slider -->
<div class="flex items-center gap-3 w-full max-w-80">
<span class="gw-text whitespace-nowrap text-sm">Poids</span>
<input
v-model="weight"
type="range"
min="0"
max="1"
step="0.01"
class="flex-1 h-2 rounded-lg appearance-none cursor-pointer accent-accent"
/>
<span class="gw-text text-sm w-10 text-right">{{ fr(w) }}</span>
</div>
<!-- Formula + result -->
<div class="w-full max-w-sm text-center p-4 rounded-lg bg-surface-200">
<p class="gw-text text-xs font-mono mb-1">
V &times; (1 + w &times; (1 &minus; S / C))
</p>
<template v-if="seniority > 0">
<p class="gw-text text-xs font-mono mb-2">
{{ fr(v) }} &times; (1 + {{ fr(w) }} &times; (1 &minus; {{ fr(s, 0) }} / {{ fr(seniority, 0) }}))
= {{ fr(v) }} &times; {{ fr(factor) }}
</p>
<p class="gw-title">= {{ fr(correctedValue) }} {{ currencyDisplay }}</p>
</template>
<p v-else class="gw-text text-xs">Renseigner l'anciennet&eacute; pour calculer</p>
</div>
</div>
</div>
<!-- Relations table (shared with CRA) -->
<GratewizardGwRelations
:items="tableItems"
:base-friends="baseFriends"
result-label="VALEUR"
@select="(f) => { otherName = f.name; otherDate = f.date; }"
@remove="removeFriend"
/>
</div>
</template>
<script setup lang="ts">
import { Block0Date, getDays, dateToString, fr, type Friend, type TableFriend } from '~/utils/gratewizard';
const todayDate = dateToString(new Date());
const baseFriends: Friend[] = [{ name: 'Bloc 0', date: Block0Date }];
// Shared state with CRA (same localStorage keys)
const currency = useLocalStorage('currency', 'DU');
const friends = useLocalStorage<Friend[]>('friends', []);
const otherName = useLocalStorage('otherName', '');
const otherDate = useLocalStorage<string | undefined>('otherDate', undefined);
// CRS-specific state
const refValue = useLocalStorage('crs-value', '1');
const balance = useLocalStorage('crs-balance', '0');
const weight = useLocalStorage('crs-weight', '0.50');
const currencyDisplay = computed(() => currency.value === 'G1' ? '\u011e1' : 'DU');
const seniority = computed(() => getDays(otherDate.value));
const s = computed(() => Number(balance.value));
const w = computed(() => Number(weight.value));
const v = computed(() => Number(refValue.value));
const netBalance = computed(() => s.value - seniority.value);
const correctedValue = computed(() =>
seniority.value > 0 ? v.value * (1 + w.value * (1 - s.value / seniority.value)) : v.value
);
const factor = computed(() =>
seniority.value > 0 ? 1 + w.value * (1 - s.value / seniority.value) : 1
);
// Friend management (shared with CRA)
function addFriend() {
if (!otherDate.value || !otherName.value) return;
if (friends.value.some((f) => f.name === otherName.value)) {
friends.value = friends.value.map((f) =>
f.name === otherName.value ? { ...f, date: otherDate.value! } : f
);
} else {
friends.value = [...friends.value, { name: otherName.value, date: otherDate.value }];
}
}
function removeFriend(name: string) {
friends.value = friends.value.filter((f) => f.name !== name);
}
const isBaseFriend = computed(() =>
baseFriends.some((f) => f.name === otherName.value && f.date === otherDate.value)
);
const isFriend = computed(() =>
friends.value.some((f) => f.name === otherName.value && f.date === otherDate.value)
);
function getCorrectedValueFor(friendDate: string): number {
const c = getDays(friendDate);
if (c <= 0) return v.value;
return v.value * (1 + w.value * (1 - s.value / c));
}
const tableItems = computed<TableFriend[]>(() => {
return baseFriends.concat(friends.value).map((f) => ({
...f,
displayName: f.name.substring(0, 10),
displayDate: new Date(f.date).toLocaleDateString('fr-FR', { dateStyle: 'short' }),
result: fr(getCorrectedValueFor(f.date)),
du: getDays(f.date),
}));
});
</script>

View File

@@ -1,272 +0,0 @@
<template>
<div class="flex flex-col gap-5 pt-4">
<!-- Error state -->
<div v-if="error" class="p-8 flex flex-col gap-4">
<p class="gw-text">Erreur : {{ error }}</p>
</div>
<template v-else>
<!-- Currency selector -->
<div class="flex items-center justify-center gap-2">
<span class="gw-text text-xs">DU</span>
<label class="gw-toggle">
<input
type="checkbox"
:checked="unit === 'G1'"
@change="unit = ($event.target as HTMLInputElement).checked ? 'G1' : 'DU'"
/>
<span class="gw-toggle-slider" />
</label>
<span class="gw-text text-xs">&#x11E;1</span>
</div>
<!-- Monetary data card -->
<div ref="topRef" class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">Masse mon&eacute;taire / Membres</p>
<span v-if="polygon" class="gw-chip">
{{ activePerimeterName ?? 'S\u00e9lection manuelle' }}
</span>
<!-- Loading -->
<div v-if="loading" class="flex flex-col items-center gap-2 py-4">
<div class="gw-spinner" />
<p class="gw-text text-sm">Connexion au r&eacute;seau...</p>
</div>
<!-- Data grid -->
<div v-else-if="monetary" class="grid grid-cols-2 gap-2 w-full max-w-sm">
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">M (masse{{ polygon ? ' locale' : '' }})</p>
<p class="gw-title mt-1">
{{ mnIsLoading ? '...' : formatValue(displayM!, unit, duDaily) }}
</p>
</div>
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">N (membres{{ polygon ? ' locaux' : '' }})</p>
<p class="gw-title mt-1">{{ displayN.toLocaleString('fr-FR') }}</p>
</div>
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">M / N{{ polygon ? ' (local)' : '' }}</p>
<p class="gw-title mt-1">
{{ mnIsLoading ? '...' : formatValue(displayMN!, unit, duDaily) }}
</p>
</div>
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
<p class="gw-text">DU journalier</p>
<p class="gw-title mt-1">{{ fr(duDaily) }} &#x11E;1</p>
</div>
<!-- Extra cells when polygon is active -->
<template v-if="polygon && nLocal > 0 && monetary">
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
<p class="gw-text">M / N (r&eacute;seau)</p>
<p class="gw-title mt-1">{{ formatValue(mnG1, unit, duDaily) }}</p>
</div>
<div class="text-center p-2 rounded-md text-xs bg-accent/10">
<p class="gw-text">Part du r&eacute;seau</p>
<p class="gw-title mt-1">{{ fr(nLocal / monetary.membersCount * 100) }} %</p>
</div>
</template>
<div class="col-span-2 text-center">
<p class="gw-text">
Bloc #{{ monetary.blockNumber }} &mdash;
{{ new Date(monetary.timestamp).toLocaleDateString('fr-FR', { dateStyle: 'long' }) }}
</p>
</div>
</div>
<!-- Clear selection button -->
<div v-if="polygon" class="flex flex-col items-center gap-2">
<button
class="gw-btn gw-btn-danger"
@click="handleClearSelection"
>
P&eacute;rim&egrave;tre monde
</button>
</div>
<!-- Saved perimeters -->
<GratewizardGwPerimeterList
:perimeters="perimeters"
@load="handleLoadPerimeter"
@delete="deletePerimeter"
/>
</div>
</div>
<!-- Map card -->
<div class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">
Carte des membres g&eacute;olocalis&eacute;s
<span v-if="!geoLoading && v1Pubkeys" class="gw-chip ml-2">
{{ geoMembers.length.toLocaleString('fr-FR') }}
</span>
</p>
<p v-if="geoError" class="gw-text">Erreur Cesium+ : {{ geoError }}</p>
<!-- Loading map -->
<div v-if="geoLoading || !v1Pubkeys" class="flex flex-col items-center gap-2 py-4">
<div class="gw-spinner" />
<p class="gw-text text-sm">Chargement des profils g&eacute;olocalis&eacute;s...</p>
</div>
<!-- Map -->
<template v-else>
<GratewizardGwMap
:members="geoMembers"
:clear-trigger="clearTrigger"
:load-polygon="loadPolygonTrigger"
@polygon-change="handlePolygonChange"
/>
<p v-if="!loading && monetary" class="gw-text">
{{ geoMembers.length.toLocaleString('fr-FR') }} membres certifi&eacute;s g&eacute;olocalis&eacute;s sur
{{ monetary.membersCount.toLocaleString('fr-FR') }} membres au total
</p>
<!-- Save perimeter form -->
<div v-if="polygon" class="flex gap-2 items-center">
<input
v-model="perimeterName"
type="text"
placeholder="Nom du p&eacute;rim&egrave;tre"
class="gw-input"
@keydown.enter="handleSavePerimeter"
/>
<button
class="gw-btn gw-btn-accent"
:disabled="!perimeterName.trim()"
@click="handleSavePerimeter"
>
Sauvegarder
</button>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { duniter, type MonetaryData } from '~/services/duniter';
import { pointInPolygon, fr, formatValue, countUdSince, type CurrencyUnit } from '~/utils/gratewizard';
import type { GeoMember } from '~/composables/useCesiumProfiles';
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
const unit = useLocalStorage<CurrencyUnit>('gw-currency-unit', 'DU');
const monetary = ref<MonetaryData | null>(null);
const v1Pubkeys = ref<string[] | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const polygon = ref<[number, number][] | null>(null);
const clearTrigger = ref(0);
const loadPolygonTrigger = ref<{ coords: [number, number][]; name: string } | null>(null);
const perimeterName = ref('');
const activePerimeterName = ref<string | null>(null);
const localMNg1 = ref<number | null>(null);
const mnLoading = ref(false);
const topRef = ref<HTMLDivElement | null>(null);
const { geoMembers, loading: geoLoading, error: geoError } = useCesiumProfiles(v1Pubkeys);
const { perimeters, savePerimeter, deletePerimeter } = useSavedPerimeters();
// Fetch monetary data on mount
onMounted(async () => {
try {
const [mon, pubkeys] = await Promise.all([
duniter.fetchMonetary(),
duniter.fetchMemberPubkeys(),
]);
monetary.value = mon;
v1Pubkeys.value = pubkeys;
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
});
const duDaily = computed(() => monetary.value ? Number(monetary.value.amount) / 100 : 1);
const massG1 = computed(() => monetary.value ? Number(monetary.value.monetaryMass) / 100 : 0);
const mnG1 = computed(() => monetary.value && monetary.value.membersCount ? massG1.value / monetary.value.membersCount : 0);
const localMembers = computed(() => {
if (!polygon.value) return [];
return geoMembers.value.filter((m) => pointInPolygon(m.lat, m.lon, polygon.value!));
});
const nLocal = computed(() => localMembers.value.length);
// Compute local M/N based on member seniority
watch([polygon, localMembers, monetary], async ([poly, members, mon]) => {
if (!poly || members.length === 0 || !mon) {
localMNg1.value = null;
return;
}
mnLoading.value = true;
try {
const pubkeys = members.map((m) => m.pubkey);
const joinBlocks = await duniter.fetchMemberJoinBlocks(pubkeys);
const udBlocks = mon.udBlockNumbers;
let totalSeniority = 0;
let validCount = 0;
for (const pk of pubkeys) {
const joinBlock = joinBlocks.get(pk);
if (joinBlock === undefined) continue;
totalSeniority += countUdSince(udBlocks, joinBlock);
validCount++;
}
if (validCount > 0) {
const localAvgSeniority = totalSeniority / validCount;
const globalAvgSeniority =
Number(mon.monetaryMass) / (Number(mon.amount) * mon.membersCount);
localMNg1.value = mnG1.value * (localAvgSeniority / globalAvgSeniority);
}
} catch (e) {
console.error('Failed to compute local M/N:', e);
} finally {
mnLoading.value = false;
}
});
const displayN = computed(() => polygon.value ? nLocal.value : monetary.value?.membersCount ?? 0);
const displayMN = computed(() => polygon.value ? localMNg1.value : mnG1.value);
const displayM = computed(() => polygon.value ? (localMNg1.value !== null ? localMNg1.value * nLocal.value : null) : massG1.value);
const mnIsLoading = computed(() => polygon.value && (mnLoading.value || localMNg1.value === null));
function handlePolygonChange(poly: [number, number][] | null, name?: string) {
polygon.value = poly;
activePerimeterName.value = name ?? null;
if (poly) nextTick(() => topRef.value?.scrollIntoView({ behavior: 'smooth' }));
}
function handleClearSelection() {
polygon.value = null;
activePerimeterName.value = null;
localMNg1.value = null;
clearTrigger.value++;
loadPolygonTrigger.value = null;
}
function handleSavePerimeter() {
if (!polygon.value || !perimeterName.value.trim()) return;
savePerimeter(perimeterName.value.trim(), polygon.value);
perimeterName.value = '';
}
function handleLoadPerimeter(p: SavedPerimeter) {
loadPolygonTrigger.value = { coords: p.polygon, name: p.name };
}
</script>

View File

@@ -1,146 +0,0 @@
<template>
<div ref="containerRef" class="gw-map" />
</template>
<script setup lang="ts">
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import '@geoman-io/leaflet-geoman-free';
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
import type { GeoMember } from '~/composables/useCesiumProfiles';
type LoadPolygon = { coords: [number, number][]; name: string } | null;
const props = defineProps<{
members: GeoMember[];
clearTrigger?: number;
loadPolygon?: LoadPolygon;
}>();
const emit = defineEmits<{
polygonChange: [polygon: [number, number][] | null, name?: string];
}>();
const containerRef = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let cluster: any = null;
let polygonLayer: L.Layer | null = null;
onMounted(() => {
if (!containerRef.value) return;
map = L.map(containerRef.value).setView([46.5, 2.5], 6);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
cluster = (L as any).markerClusterGroup();
map.addLayer(cluster);
// Geoman controls
if (map.pm) {
map.pm.addControls({
position: 'topleft',
drawCircle: false,
drawCircleMarker: false,
drawMarker: false,
drawPolyline: false,
drawText: false,
editMode: false,
dragMode: false,
cutPolygon: false,
rotateMode: false,
removalMode: false,
});
map.pm.setLang('fr');
}
map.on('pm:create', (e: any) => {
if (polygonLayer) {
map!.removeLayer(polygonLayer);
}
polygonLayer = e.layer;
const latlngs = (e.layer as L.Polygon).getLatLngs()[0] as L.LatLng[];
emit('polygonChange', latlngs.map((ll) => [ll.lat, ll.lng]));
});
map.on('pm:remove', () => {
polygonLayer = null;
emit('polygonChange', null);
});
// Populate initial markers
updateMarkers(props.members);
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
cluster = null;
}
});
// Clear polygon when clearTrigger changes
watch(() => props.clearTrigger, () => {
if (!props.clearTrigger || !map) return;
if (polygonLayer) {
const center = map.getCenter();
const zoom = map.getZoom();
map.removeLayer(polygonLayer);
polygonLayer = null;
map.setView(center, zoom, { animate: false });
}
if (map.pm) {
map.pm.disableDraw();
}
});
// Load a saved polygon
watch(() => props.loadPolygon, (loadPolygon) => {
if (!map || !loadPolygon) return;
if (polygonLayer) {
map.removeLayer(polygonLayer);
polygonLayer = null;
}
const latlngs = loadPolygon.coords.map(([lat, lng]) => L.latLng(lat, lng));
const poly = L.polygon(latlngs, { color: '#3388ff' }).addTo(map);
polygonLayer = poly;
map.fitBounds(poly.getBounds(), { padding: [20, 20] });
emit('polygonChange', loadPolygon.coords, loadPolygon.name);
});
// Update markers when members change
function updateMarkers(members: GeoMember[]) {
if (!cluster) return;
cluster.clearLayers();
const markers = members.map((m) =>
L.circleMarker([m.lat, m.lon], {
radius: 6,
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.7,
}).bindPopup(
`<strong>${m.title || m.pubkey.slice(0, 8)}</strong>${m.city ? '<br>' + m.city : ''}`
)
);
cluster.addLayers(markers);
}
watch(() => props.members, (members) => {
updateMarkers(members);
});
</script>
<style scoped>
.gw-map {
height: 500px;
width: 100%;
border-radius: 0.75rem;
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<div v-if="perimeters.length > 0" class="w-full flex flex-col gap-1">
<p class="text-xs font-semibold text-white/50 uppercase tracking-wide">P&eacute;rim&egrave;tres sauvegard&eacute;s</p>
<div class="flex flex-col gap-0.5 w-full">
<div
v-for="p in perimeters"
:key="p.name"
class="gw-perimeter-item"
@click="emit('load', p)"
>
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-accent text-xs shrink-0">&#x1F4CD;</span>
<span class="text-xs font-medium truncate">{{ p.name }}</span>
<span class="text-[10px] text-white/30 shrink-0">
{{ new Date(p.createdAt).toLocaleDateString('fr-FR', { dateStyle: 'short' }) }}
</span>
</div>
<button
class="gw-perimeter-delete"
@click.stop="emit('delete', p.name)"
>
&times;
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
defineProps<{
perimeters: SavedPerimeter[];
}>();
const emit = defineEmits<{
load: [perimeter: SavedPerimeter];
delete: [name: string];
}>();
</script>

View File

@@ -1,141 +0,0 @@
<template>
<div class="card-surface !p-0">
<div class="flex items-center justify-center py-3 px-4">
<p class="gw-metric text-sm">Mes relations</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-white/8">
<th
v-for="col in columns"
:key="col.key"
class="px-3 py-2 text-left text-xs font-semibold text-white/60 uppercase cursor-pointer select-none hover:text-white/80 transition-colors"
@click="col.sortable && toggleSort(col.key)"
>
<span class="inline-flex items-center gap-1">
{{ col.label }}
<span v-if="col.sortable && sortKey === col.key" class="text-accent">
{{ sortAsc ? '\u25B2' : '\u25BC' }}
</span>
</span>
</th>
<th class="px-3 py-2 w-8" />
</tr>
</thead>
<tbody>
<tr v-if="sortedItems.length === 0">
<td :colspan="columns.length + 1" class="px-3 py-3 text-center text-white/40 text-xs">
Aucune relation
</td>
</tr>
<tr
v-for="(friend, index) in sortedItems"
:key="friend.name"
class="cursor-pointer border-b border-white/5 transition-colors"
:class="index % 2 === 0 ? 'bg-surface-200/50' : ''"
@click="emit('select', friend)"
>
<td class="px-3 py-1.5">{{ friend.displayName }}</td>
<td v-if="resultLabel" class="px-3 py-1.5">{{ friend.result }}</td>
<td class="px-3 py-1.5">{{ friend.displayDate }}</td>
<td class="px-3 py-1.5">{{ friend.du }}</td>
<td class="px-3 py-1.5 w-8">
<button
v-if="!isBaseFriend(friend)"
class="text-red-400 hover:text-red-300 transition-colors"
title="Effacer relation"
@click.stop="emit('remove', friend.name)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { Block0Date, type TableFriend } from '~/utils/gratewizard';
const props = defineProps<{
items: TableFriend[];
baseFriends: { name: string; date: string }[];
resultLabel?: string;
}>();
const emit = defineEmits<{
select: [friend: TableFriend];
remove: [name: string];
}>();
const sortKey = ref<string>('name');
const sortAsc = ref(true);
const columns = computed(() => {
const cols = [
{ key: 'name', label: 'NOM', sortable: true },
];
if (props.resultLabel) {
cols.push({ key: 'result', label: props.resultLabel, sortable: true });
}
cols.push(
{ key: 'date', label: 'DATE', sortable: true },
{ key: 'du', label: 'DU', sortable: true },
);
return cols;
});
function toggleSort(key: string) {
if (sortKey.value === key) {
sortAsc.value = !sortAsc.value;
} else {
sortKey.value = key;
sortAsc.value = true;
}
}
const sortedItems = computed(() => {
const baseNames = new Set(props.baseFriends.map((f) => f.name));
const bases: TableFriend[] = [];
const others: TableFriend[] = [];
for (const item of props.items) {
if (baseNames.has(item.name)) bases.push(item);
else others.push(item);
}
const key = sortKey.value;
const dir = sortAsc.value ? 1 : -1;
others.sort((a, b) => {
const first = a[key];
const second = b[key];
let cmp = 0;
switch (key) {
case 'name':
cmp = String(first).localeCompare(String(second));
break;
case 'result':
case 'du':
cmp = Number(first) - Number(second);
break;
case 'date':
cmp = new Date(a.date).getTime() - new Date(b.date).getTime();
break;
}
return cmp * dir;
});
return [...bases, ...others];
});
function isBaseFriend(friend: TableFriend): boolean {
return props.baseFriends.some(({ name, date }) => name === friend.name && date === friend.date);
}
</script>

View File

@@ -1,41 +0,0 @@
<template>
<div class="flex flex-col items-center">
<p class="gw-metric pb-3" style="font-size: 1.75rem">grateWizard</p>
<!-- Tab buttons -->
<div class="flex gap-0.5 p-0.5 rounded-full bg-surface-200">
<button
v-for="tab in tabs"
:key="tab.key"
class="gw-tab-btn"
:class="activeTab === tab.key
? 'gw-tab-active'
: 'gw-tab-inactive'"
:disabled="tab.disabled"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Tab content with KeepAlive -->
<div class="w-full">
<KeepAlive>
<GratewizardGwMN v-if="activeTab === '1'" />
<GratewizardGwCRA v-else-if="activeTab === '2'" />
<GratewizardGwCRS v-else-if="activeTab === '3'" />
</KeepAlive>
</div>
</div>
</template>
<script setup lang="ts">
const tabs = [
{ key: '1', label: 'M/N local', disabled: false },
{ key: '2', label: "A l'anciennet\u00e9", disabled: false },
{ key: '3', label: 'Au solde', disabled: false },
{ key: '4', label: 'Au volume', disabled: true },
];
const activeTab = ref('1');
</script>

View File

@@ -5,106 +5,107 @@
ref="widgetRef" ref="widgetRef"
class="mini-player" class="mini-player"
> >
<!-- Expanded panel (above the pill) --> <!-- EXPANDED PANEL -->
<Transition name="panel-expand"> <Transition name="panel-expand">
<div v-if="isExpanded" class="mini-panel"> <div v-if="isExpanded" class="mini-panel">
<!-- Visualizer --> <!-- Track info + visualizer -->
<div class="mini-panel-section"> <div class="panel-top">
<KeepAlive> <div class="panel-track">
<PlayerVisualizer /> <p class="panel-title">{{ store.currentSong.title }}</p>
</KeepAlive> <p class="panel-artist">{{ store.currentSong.artist }}</p>
</div>
<div class="panel-viz">
<KeepAlive>
<PlayerVisualizer />
</KeepAlive>
</div>
</div> </div>
<!-- Progress bar + controls --> <!-- Progress -->
<div class="mini-panel-section"> <div class="panel-progress">
<PlayerProgress /> <PlayerProgress />
<div class="mt-3 flex items-center justify-center"> <div class="panel-times">
<PlayerControls /> <span>{{ store.formattedCurrentTime }}</span>
<span>{{ store.formattedDuration }}</span>
</div> </div>
</div> </div>
<!-- Volume + mode + time --> <!-- Controls -->
<div class="mini-panel-section mini-panel-row"> <div class="panel-controls">
<PlayerModeToggle /> <PlayerControls />
<div class="flex items-center gap-2"> </div>
<button class="btn-ghost !p-1" @click="toggleMute">
<div :class="volumeIcon" class="h-4 w-4" /> <!-- Volume -->
</button> <div class="panel-volume-row">
<input <button class="panel-vol-btn" @click="toggleMute">
type="range" <div :class="volumeIcon" class="h-3.5 w-3.5" />
min="0" </button>
max="1" <input
step="0.01" type="range"
:value="store.volume" min="0"
class="volume-slider w-20" max="1"
@input="handleVolumeChange" step="0.01"
> :value="store.volume"
</div> class="volume-slider"
<span class="font-mono text-xs text-white/40"> @input="handleVolumeChange"
{{ store.formattedCurrentTime }} / {{ store.formattedDuration }} >
</span> </div>
<!-- Lyrics (if available) -->
<div v-if="store.currentSong.lyrics" class="panel-lyrics">
<pre class="panel-lyrics-text">{{ store.currentSong.lyrics }}</pre>
</div> </div>
<!-- Playlist --> <!-- Playlist -->
<div class="mini-panel-playlist"> <div class="panel-playlist">
<PlayerPlaylist /> <PlayerPlaylist />
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- Compact pill (always visible) --> <!-- COMPACT PILL -->
<div class="mini-pill" @click="onPillClick"> <div class="mini-pill" @click="toggleExpanded">
<!-- SVG circular progress ring --> <!-- Progress ring -->
<div class="mini-pill-ring"> <div class="pill-ring">
<svg viewBox="0 0 40 40" class="mini-pill-svg"> <svg viewBox="0 0 36 36" class="pill-ring-svg">
<circle <circle
cx="20" cy="20" r="18" cx="18" cy="18" r="16"
fill="none" fill="none"
stroke="hsl(0 0% 100% / 0.1)" stroke="hsl(0 0% 100% / 0.06)"
stroke-width="2.5" stroke-width="2"
/> />
<circle <circle
cx="20" cy="20" r="18" cx="18" cy="18" r="16"
fill="none" fill="none"
stroke="hsl(12 76% 48%)" stroke="hsl(12 76% 48%)"
stroke-width="2.5" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
:stroke-dasharray="circumference" :stroke-dasharray="circumference"
:stroke-dashoffset="circumference - (circumference * store.progress / 100)" :stroke-dashoffset="circumference - (circumference * store.progress / 100)"
class="mini-pill-progress" class="pill-ring-progress"
/> />
</svg> </svg>
<!-- Cover image inside ring -->
<div class="mini-pill-cover">
<img
v-if="store.currentSong.coverImage"
:src="store.currentSong.coverImage"
:alt="store.currentSong.title"
class="h-full w-full object-cover"
>
<div v-else class="i-lucide-music h-4 w-4 text-primary" />
</div>
</div> </div>
<!-- Title --> <!-- Title -->
<span class="mini-pill-title">{{ store.currentSong.title }}</span> <span class="pill-title">{{ store.currentSong.title }}</span>
<!-- Play/Pause --> <!-- Play/Pause -->
<button <button
class="mini-pill-btn" class="pill-play"
:aria-label="store.isPlaying ? 'Pause' : 'Lecture'" :aria-label="store.isPlaying ? 'Pause' : 'Lecture'"
@click.stop="togglePlayPause" @click.stop="togglePlayPause"
> >
<div :class="store.isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" class="h-4 w-4" /> <div :class="store.isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" class="h-4 w-4" />
</button> </button>
<!-- Expand chevron --> <!-- Expand -->
<button <button
class="mini-pill-btn" class="pill-expand"
:aria-label="isExpanded ? 'Réduire' : 'Développer'" :aria-label="isExpanded ? 'Réduire' : 'Développer'"
@click.stop="toggleExpanded" @click.stop="toggleExpanded"
> >
<div :class="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" /> <div :class="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
@@ -123,7 +124,7 @@ const widgetRef = ref<HTMLElement>()
const isExpanded = ref(false) const isExpanded = ref(false)
let previousVolume = 0.8 let previousVolume = 0.8
const circumference = 2 * Math.PI * 18 // r=18 const circumference = 2 * Math.PI * 16
const volumeIcon = computed(() => { const volumeIcon = computed(() => {
if (store.volume === 0) return 'i-lucide-volume-x' if (store.volume === 0) return 'i-lucide-volume-x'
@@ -151,11 +152,6 @@ function toggleExpanded() {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
function onPillClick() {
isExpanded.value = !isExpanded.value
}
// Close expanded panel on click outside
onClickOutside(widgetRef, () => { onClickOutside(widgetRef, () => {
if (isExpanded.value) isExpanded.value = false if (isExpanded.value) isExpanded.value = false
}) })
@@ -163,168 +159,247 @@ onClickOutside(widgetRef, () => {
<style scoped> <style scoped>
/* ═══════════════════════════════════════ /* ═══════════════════════════════════════
MINI-PLAYER FLOATING WIDGET POSITION
═══════════════════════════════════════ */ ═══════════════════════════════════════ */
.mini-player { .mini-player {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
right: 1rem; right: max(1rem, calc((100vw - 80rem) / 2));
z-index: 70; z-index: 70;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
max-width: 360px;
} }
/* ─── COMPACT PILL ─── */ /* ═══════════════════════════════════════
PILL
═══════════════════════════════════════ */
.mini-pill { .mini-pill {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.625rem;
padding: 0.375rem 0.625rem 0.375rem 0.375rem; padding: 0.375rem 0.5rem 0.375rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
background: hsl(20 8% 8% / 0.9); background: hsl(20 8% 7% / 0.92);
backdrop-filter: blur(16px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(24px);
border: 1px solid hsl(0 0% 100% / 0.1);
cursor: pointer; cursor: pointer;
transition: all 0.3s var(--ease-out-expo); transition: all 0.3s var(--ease-out-expo);
box-shadow: 0 4px 24px hsl(0 0% 0% / 0.4); box-shadow: 0 4px 20px hsl(0 0% 0% / 0.35);
} }
.mini-pill:hover { .mini-pill:hover {
border-color: hsl(12 76% 48% / 0.3); background: hsl(20 8% 9% / 0.96);
box-shadow: 0 4px 32px hsl(12 76% 48% / 0.15);
} }
.mini-pill-ring { /* Progress ring */
position: relative; .pill-ring {
width: 2.25rem; width: 1.75rem;
height: 2.25rem; height: 1.75rem;
flex-shrink: 0; flex-shrink: 0;
} }
.mini-pill-svg { .pill-ring-svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.mini-pill-progress { .pill-ring-progress {
transition: stroke-dashoffset 0.3s ease; transition: stroke-dashoffset 0.3s ease;
} }
.mini-pill-cover {
position: absolute;
inset: 4px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: hsl(20 8% 12%);
}
.mini-pill-title { /* Title */
.pill-title {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: white; color: hsl(0 0% 100% / 0.8);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 140px; max-width: 160px;
} }
.mini-pill-btn { /* Play/Pause — white circle */
.pill-play {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.75rem; width: 2rem;
height: 1.75rem; height: 2rem;
border-radius: 50%;
background: white;
border: none;
color: hsl(20 8% 6%);
cursor: pointer;
transition: transform 0.15s var(--ease-out-expo);
flex-shrink: 0;
}
.pill-play:hover { transform: scale(1.08); }
.pill-play:active { transform: scale(0.94); }
/* Expand chevron */
.pill-expand {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%; border-radius: 50%;
background: transparent; background: transparent;
border: none; border: none;
color: hsl(0 0% 100% / 0.7); color: hsl(0 0% 100% / 0.3);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: color 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
.mini-pill-btn:hover { .pill-expand:hover { color: hsl(0 0% 100% / 0.7); }
color: white;
background: hsl(0 0% 100% / 0.1);
}
/* ─── EXPANDED PANEL ─── */ /* ═══════════════════════════════════════
PANEL
═══════════════════════════════════════ */
.mini-panel { .mini-panel {
width: 340px; width: 360px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border-radius: 1rem; border-radius: 1rem;
background: hsl(20 8% 6% / 0.95); background: hsl(20 8% 6% / 0.94);
backdrop-filter: blur(20px); backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(32px);
border: 1px solid hsl(0 0% 100% / 0.08); box-shadow: 0 8px 40px hsl(0 0% 0% / 0.4);
box-shadow: 0 8px 40px hsl(0 0% 0% / 0.5);
overflow: hidden; overflow: hidden;
} }
.mini-panel-section { /* ─── Track + visualizer ─── */
padding: 0.75rem 1rem; .panel-top {
border-bottom: 1px solid hsl(0 0% 100% / 0.06); padding: 1rem 1.25rem 0.5rem;
} }
.mini-panel-section:last-child { .panel-track {
border-bottom: none; margin-bottom: 0.5rem;
}
.panel-title {
font-size: 0.95rem;
font-weight: 600;
color: white;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-artist {
font-size: 0.75rem;
color: hsl(0 0% 100% / 0.35);
margin-top: 0.125rem;
}
.panel-viz {
opacity: 0.6;
} }
.mini-panel-row { /* ─── Progress ─── */
.panel-progress {
padding: 0.5rem 1.25rem 0;
}
.panel-times {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.625rem;
color: hsl(0 0% 100% / 0.25);
letter-spacing: 0.02em;
}
/* ─── Controls ─── */
.panel-controls {
display: flex;
justify-content: center;
padding: 0.25rem 1.25rem 0.375rem;
}
/* ─── Volume ─── */
.panel-volume-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding: 0 1.25rem 0.75rem;
} }
.panel-vol-btn {
.mini-panel-playlist { display: flex;
max-height: 240px; align-items: center;
overflow-y: auto; justify-content: center;
background: transparent;
border: none;
color: hsl(0 0% 100% / 0.35);
cursor: pointer;
padding: 0;
transition: color 0.2s;
} }
.panel-vol-btn:hover { color: hsl(0 0% 100% / 0.7); }
/* ─── VOLUME SLIDER ─── */
.volume-slider { .volume-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 4px; flex: 1;
background: hsl(0 0% 100% / 0.15); height: 3px;
background: hsl(0 0% 100% / 0.08);
border-radius: 2px; border-radius: 2px;
outline: none; outline: none;
} }
.volume-slider::-webkit-slider-thumb { .volume-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 12px; width: 10px;
height: 12px; height: 10px;
background: white; background: hsl(0 0% 100% / 0.7);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: transform 0.15s;
} }
.volume-slider::-webkit-slider-thumb:hover { transform: scale(1.3); }
.volume-slider::-moz-range-thumb { .volume-slider::-moz-range-thumb {
width: 12px; width: 10px;
height: 12px; height: 10px;
background: white; background: hsl(0 0% 100% / 0.7);
border: none; border: none;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
} }
/* ─── TRANSITIONS ─── */ /* ─── Lyrics ─── */
.panel-lyrics {
max-height: 160px;
overflow-y: auto;
padding: 0.75rem 1.25rem;
border-top: 1px solid hsl(0 0% 100% / 0.04);
}
.panel-lyrics-text {
font-family: var(--font-sans, sans-serif);
font-size: 0.75rem;
line-height: 1.6;
color: hsl(0 0% 100% / 0.4);
white-space: pre-wrap;
margin: 0;
}
/* ─── Playlist ─── */
.panel-playlist {
max-height: 200px;
overflow-y: auto;
border-top: 1px solid hsl(0 0% 100% / 0.04);
}
/* ═══════════════════════════════════════
TRANSITIONS
═══════════════════════════════════════ */
.player-slide-enter-active, .player-slide-enter-active,
.player-slide-leave-active { .player-slide-leave-active {
transition: transform 0.3s var(--ease-out-expo), opacity 0.3s var(--ease-out-expo); transition: transform 0.35s var(--ease-out-expo), opacity 0.35s var(--ease-out-expo);
} }
.player-slide-enter-from, .player-slide-enter-from,
.player-slide-leave-to { .player-slide-leave-to {
transform: translateY(20px); transform: translateY(16px);
opacity: 0; opacity: 0;
} }
.panel-expand-enter-active, .panel-expand-enter-active,
.panel-expand-leave-active { .panel-expand-leave-active {
transition: all 0.3s var(--ease-out-expo); transition: all 0.35s var(--ease-out-expo);
overflow: hidden; overflow: hidden;
} }
.panel-expand-enter-from, .panel-expand-enter-from,
@@ -335,25 +410,21 @@ onClickOutside(widgetRef, () => {
} }
.panel-expand-enter-to, .panel-expand-enter-to,
.panel-expand-leave-from { .panel-expand-leave-from {
max-height: 600px; max-height: 800px;
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
/* ─── MOBILE ─── */ /* ═══════════════════════════════════════
MOBILE
═══════════════════════════════════════ */
@media (max-width: 768px) { @media (max-width: 768px) {
.mini-player { .mini-player {
right: 0.75rem; right: 0.75rem;
left: 0.75rem; left: 0.75rem;
max-width: none;
align-items: stretch; align-items: stretch;
} }
.mini-panel { .mini-panel { width: auto; }
width: auto; .pill-title { max-width: none; flex: 1; }
}
.mini-pill-title {
max-width: none;
flex: 1;
}
} }
</style> </style>

View File

@@ -1,97 +0,0 @@
const CESIUM_PODS = [
'https://g1.data.brussels.ovh/user/profile/_search',
'https://g1.data.le-sou.org/user/profile/_search',
'https://g1.data.e-is.pro/user/profile/_search',
];
const BATCH_SIZE = 500;
export type GeoMember = {
pubkey: string;
title: string;
city: string;
lat: number;
lon: number;
};
/** Find the first Cesium+ pod that responds successfully. */
async function findWorkingPod(): Promise<string> {
for (const url of CESIUM_PODS) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 0, query: { match_all: {} } }),
});
if (res.ok) return url;
} catch {
// try next pod
}
}
throw new Error('Aucun pod Cesium+ disponible');
}
/**
* Fetch Cesium+ profiles for a given list of v1 pubkeys.
* Uses Elasticsearch `ids` query with batches of 500, filtered to geolocated profiles only.
* Pass `null` to skip fetching (e.g. while pubkeys are still loading).
*/
export function useCesiumProfiles(v1Pubkeys: Ref<string[] | null>) {
const geoMembers = ref<GeoMember[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
watch(v1Pubkeys, async (pubkeys) => {
if (pubkeys === null) return;
loading.value = true;
error.value = null;
try {
const podUrl = await findWorkingPod();
const allMembers: GeoMember[] = [];
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
const batch = pubkeys.slice(i, i + BATCH_SIZE);
const res = await fetch(podUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
size: batch.length,
_source: ['title', 'city', 'geoPoint'],
query: {
bool: {
must: [
{ ids: { values: batch } },
{ exists: { field: 'geoPoint' } },
],
},
},
}),
});
if (!res.ok) throw new Error(`Cesium+ HTTP ${res.status}`);
const json = await res.json();
for (const hit of json.hits?.hits ?? []) {
const s = hit._source;
if (!s?.geoPoint?.lat || !s?.geoPoint?.lon) continue;
allMembers.push({
pubkey: hit._id,
title: s.title || '',
city: s.city || '',
lat: s.geoPoint.lat,
lon: s.geoPoint.lon,
});
}
}
geoMembers.value = allMembers;
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
}, { immediate: true });
return { geoMembers, loading, error };
}

View File

@@ -8,7 +8,7 @@ export function useGrateWizard() {
const win = window.open( const win = window.open(
url, url,
'grateWizard', 'grateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`, `width=${popup.width},height=${popup.height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=yes,resizable=yes`,
) )
if (win) e?.preventDefault() if (win) e?.preventDefault()
} }

View File

@@ -1,24 +0,0 @@
export type SavedPerimeter = {
name: string;
polygon: [number, number][];
createdAt: string;
};
const STORAGE_KEY = 'gw-saved-perimeters';
export function useSavedPerimeters() {
const perimeters = useLocalStorage<SavedPerimeter[]>(STORAGE_KEY, []);
function savePerimeter(name: string, polygon: [number, number][]) {
perimeters.value = [
...perimeters.value.filter((p) => p.name !== name),
{ name, polygon, createdAt: new Date().toISOString() },
];
}
function deletePerimeter(name: string) {
perimeters.value = perimeters.value.filter((p) => p.name !== name);
}
return { perimeters, savePerimeter, deletePerimeter };
}

View File

@@ -1,15 +1,5 @@
<template> <template>
<!-- Popup mode: standalone app, no Librodrome layout --> <NuxtLayout>
<main v-if="isPopup" class="gw-app flex flex-col items-center sm:p-4 overflow-x-hidden h-screen">
<div class="sm:max-w-screen-sm w-full">
<div class="gw-card">
<GratewizardGwTabs />
</div>
</div>
</main>
<!-- Info mode: Librodrome layout with feature cards -->
<NuxtLayout v-else>
<div class="section-padding"> <div class="section-padding">
<div class="container-content max-w-3xl mx-auto"> <div class="container-content max-w-3xl mx-auto">
<!-- Back link --> <!-- Back link -->
@@ -70,36 +60,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ layout: false })
const route = useRoute()
const isPopup = computed(() => route.query.popup !== undefined)
const { data: content } = await usePageContent('gratewizard') const { data: content } = await usePageContent('gratewizard')
useHead(isPopup.value useHead({
? { title: content.value?.meta?.title ?? 'grateWizard \u2014 Coefficients relatifs',
title: 'grateWizard', })
htmlAttrs: { lang: 'fr' },
meta: [{ name: 'color-scheme', content: 'dark' }],
link: [
{ rel: 'preconnect', href: 'https://fonts.bunny.net' },
{
rel: 'stylesheet',
href: 'https://fonts.bunny.net/css?family=space-grotesk:300,400,500,600,700',
},
],
}
: {
title: content.value?.meta?.title ?? 'grateWizard \u2014 Coefficients relatifs',
},
)
const { url, launch } = useGrateWizard() const { url, launch } = useGrateWizard()
</script> </script>
<style scoped> <style scoped>
/* Info page styles */
.page-title { .page-title {
font-size: clamp(2rem, 5vw, 2.75rem); font-size: clamp(2rem, 5vw, 2.75rem);
} }
@@ -125,301 +95,3 @@ code {
background: hsl(40 80% 50% / 0.1); background: hsl(40 80% 50% / 0.1);
} }
</style> </style>
<style>
/* Standalone popup app styles — unscoped so child components inherit */
.gw-app {
font-family: 'Space Grotesk', sans-serif;
background-color: hsl(20 8% 3.5%);
color: white;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 0.875rem;
}
.gw-app button,
.gw-app select {
border: 0;
}
.gw-card {
border-radius: 0.75rem;
border: 1px solid hsl(20 8% 18% / 0.5);
background: hsl(20 8% 8%);
padding: 1rem 1.25rem;
}
/* --- Typography --- */
.gw-metric {
font-weight: 600;
text-align: center;
font-size: 1rem;
color: white;
}
.gw-title {
font-weight: 500;
font-size: 0.875rem;
color: white;
}
.gw-text {
font-size: 0.8125rem;
color: hsl(0 0% 75%);
}
.gw-chip {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
background: hsl(36 80% 52% / 0.15);
color: hsl(36 80% 52%);
}
/* --- Tab buttons --- */
.gw-tab-btn {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
}
.gw-tab-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.gw-tab-active {
background: hsl(36 80% 52%);
color: hsl(20 8% 3.5%);
box-shadow: 0 1px 3px hsl(36 80% 52% / 0.3);
}
.gw-tab-inactive {
color: hsl(0 0% 100% / 0.5);
}
.gw-tab-inactive:not(:disabled):hover {
color: hsl(0 0% 100% / 0.8);
background: hsl(0 0% 100% / 0.05);
}
/* --- Buttons --- */
.gw-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3125rem 0.875rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
border: none;
outline: none;
}
.gw-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.gw-btn-accent {
background: hsl(36 80% 52%);
color: hsl(20 8% 3.5%);
box-shadow: 0 1px 2px hsl(36 80% 52% / 0.25);
}
.gw-btn-accent:not(:disabled):hover {
background: hsl(36 80% 58%);
box-shadow: 0 2px 6px hsl(36 80% 52% / 0.3);
}
.gw-btn-accent:not(:disabled):active {
background: hsl(36 80% 46%);
transform: scale(0.97);
}
.gw-btn-danger {
background: hsl(0 72% 50%);
color: white;
box-shadow: 0 1px 2px hsl(0 72% 50% / 0.25);
}
.gw-btn-danger:not(:disabled):hover {
background: hsl(0 72% 56%);
box-shadow: 0 2px 6px hsl(0 72% 50% / 0.3);
}
.gw-btn-danger:not(:disabled):active {
background: hsl(0 72% 44%);
transform: scale(0.97);
}
/* --- Inputs --- */
.gw-input {
padding: 0.3125rem 0.625rem;
font-size: 0.8125rem;
border-radius: 0.375rem;
border: 1px solid hsl(20 8% 22%);
background: hsl(20 8% 6%);
color: white;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.gw-input:focus {
border-color: hsl(36 80% 52%);
box-shadow: 0 0 0 2px hsl(36 80% 52% / 0.12);
}
/* --- Icon button --- */
.gw-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: hsl(36 80% 52%);
color: hsl(20 8% 3.5%);
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.gw-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.gw-icon-btn:not(:disabled):hover {
background: hsl(36 80% 58%);
box-shadow: 0 2px 6px hsl(36 80% 52% / 0.3);
}
.gw-icon-btn:not(:disabled):active {
transform: scale(0.92);
}
/* --- Toggle --- */
.gw-toggle {
position: relative;
display: inline-block;
width: 2.25rem;
height: 1.25rem;
}
.gw-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.gw-toggle-slider {
position: absolute;
inset: 0;
border-radius: 9999px;
background: hsl(20 8% 22%);
transition: background 0.2s;
cursor: pointer;
}
.gw-toggle-slider::before {
content: '';
position: absolute;
height: 0.875rem;
width: 0.875rem;
left: 0.1875rem;
bottom: 0.1875rem;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.gw-toggle input:checked + .gw-toggle-slider {
background: hsl(36 80% 52%);
}
.gw-toggle input:checked + .gw-toggle-slider::before {
transform: translateX(1rem);
}
/* --- Spinner --- */
.gw-spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid hsl(20 8% 22%);
border-top-color: hsl(36 80% 52%);
border-radius: 50%;
animation: gw-spin 0.8s linear infinite;
}
@keyframes gw-spin {
to { transform: rotate(360deg); }
}
/* --- Card surface --- */
.card-surface {
border-radius: 0.625rem;
background: hsl(20 8% 7%);
border: 1px solid hsl(0 0% 100% / 0.06);
padding: 1rem;
transition: all 0.3s;
}
/* --- Perimeter list items --- */
.gw-perimeter-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(20 8% 12%);
cursor: pointer;
transition: background 0.15s ease;
}
.gw-perimeter-item:hover {
background: hsl(20 8% 16%);
}
.gw-perimeter-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
font-size: 0.875rem;
line-height: 1;
color: hsl(0 60% 60%);
background: hsl(0 60% 50% / 0.1);
border: none;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.gw-perimeter-delete:hover {
background: hsl(0 60% 50% / 0.2);
color: hsl(0 60% 70%);
}
/* --- Scrollbar --- */
.gw-app ::-webkit-scrollbar {
width: 5px;
}
.gw-app ::-webkit-scrollbar-track {
background: transparent;
}
.gw-app ::-webkit-scrollbar-thumb {
background: hsl(0 0% 100% / 0.12);
border-radius: 3px;
}
</style>

View File

@@ -1,4 +0,0 @@
// Changer cette ligne pour switcher v1 → v2 après mars 2026 :
import { v1Adapter as duniter } from './v1';
export { duniter };
export type { DuniterAdapter, MonetaryData } from './types';

View File

@@ -1,14 +0,0 @@
export type MonetaryData = {
monetaryMass: string;
membersCount: number;
amount: string;
timestamp: string;
blockNumber: number;
udBlockNumbers: number[];
};
export interface DuniterAdapter {
fetchMonetary(): Promise<MonetaryData>;
fetchMemberPubkeys(): Promise<string[]>;
fetchMemberJoinBlocks(pubkeys: string[]): Promise<Map<string, number>>;
}

View File

@@ -1,83 +0,0 @@
import type { DuniterAdapter, MonetaryData } from './types';
const BMA_URL = 'https://g1.duniter.org';
async function bmaGet<T>(path: string): Promise<T> {
const res = await fetch(`${BMA_URL}${path}`);
if (!res.ok) throw new Error(`BMA ${path}: ${res.status}`);
return res.json();
}
const joinBlockCache = new Map<string, number>();
export const v1Adapter: DuniterAdapter = {
async fetchMonetary(): Promise<MonetaryData> {
const [current, udBlocks] = await Promise.all([
bmaGet<{
monetaryMass: number;
membersCount: number;
number: number;
medianTime: number;
}>('/blockchain/current'),
bmaGet<{ result: { blocks: number[] } }>('/blockchain/with/ud'),
]);
const udBlockNumbers = udBlocks.result.blocks;
const lastUdBlock = udBlockNumbers[udBlockNumbers.length - 1];
const udBlock = await bmaGet<{ dividend: number }>(`/blockchain/block/${lastUdBlock}`);
return {
monetaryMass: String(current.monetaryMass),
membersCount: current.membersCount,
amount: String(udBlock.dividend),
timestamp: new Date(current.medianTime * 1000).toISOString(),
blockNumber: current.number,
udBlockNumbers,
};
},
async fetchMemberPubkeys(): Promise<string[]> {
const data = await bmaGet<{ results: { pubkey: string }[] }>('/wot/members');
return data.results.map((m) => m.pubkey);
},
async fetchMemberJoinBlocks(pubkeys: string[]): Promise<Map<string, number>> {
const result = new Map<string, number>();
const toFetch: string[] = [];
for (const pk of pubkeys) {
const cached = joinBlockCache.get(pk);
if (cached !== undefined) {
result.set(pk, cached);
} else {
toFetch.push(pk);
}
}
const CONCURRENT = 10;
for (let i = 0; i < toFetch.length; i += CONCURRENT) {
const batch = toFetch.slice(i, i + CONCURRENT);
await Promise.all(
batch.map(async (pk) => {
try {
const data = await bmaGet<{
results: { uids: { meta: { timestamp: string } }[] }[];
}>(`/wot/lookup/${encodeURIComponent(pk)}`);
const ts = data.results?.[0]?.uids?.[0]?.meta?.timestamp;
if (ts) {
const blockNum = parseInt(ts.split('-')[0], 10);
if (!isNaN(blockNum)) {
joinBlockCache.set(pk, blockNum);
result.set(pk, blockNum);
}
}
} catch {
// Skip members we can't look up
}
})
);
}
return result;
},
};

View File

@@ -1,68 +0,0 @@
import { ss58ToV1Pubkey } from '~/utils/ss58';
import type { DuniterAdapter, MonetaryData } from './types';
const SQUID_URL = 'https://gt-squid.axiom-team.fr/v1/graphql';
async function gql<T>(query: string): Promise<T> {
const res = await fetch(SQUID_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const json = await res.json();
if (json.errors) throw new Error(json.errors[0].message);
return json.data;
}
export const v2Adapter: DuniterAdapter = {
async fetchMonetary(): Promise<MonetaryData> {
const data = await gql<{
universalDividends: { nodes: (Omit<MonetaryData, 'udBlockNumbers'> & Record<string, unknown>)[] };
}>(`{
universalDividends(first: 1, orderBy: BLOCK_NUMBER_DESC) {
nodes { monetaryMass membersCount amount timestamp blockNumber }
}
}`);
return { ...data.universalDividends.nodes[0], udBlockNumbers: [] };
},
async fetchMemberPubkeys(): Promise<string[]> {
const accountIds: string[] = [];
let offset = 0;
const pageSize = 1000;
while (true) {
const data = await gql<{
identities: { nodes: { accountId: string }[] };
}>(`{
identities(first: ${pageSize}, offset: ${offset}, filter: { isMember: { equalTo: true } }) {
nodes { accountId }
}
}`);
const nodes = data.identities.nodes;
for (const node of nodes) {
accountIds.push(node.accountId);
}
if (nodes.length < pageSize) break;
offset += pageSize;
}
// Convert SS58 accountIds to Cesium+ v1 base58 pubkeys
const pubkeys: string[] = [];
for (const id of accountIds) {
try {
pubkeys.push(ss58ToV1Pubkey(id));
} catch {
// Skip invalid addresses
}
}
return pubkeys;
},
async fetchMemberJoinBlocks(_pubkeys: string[]): Promise<Map<string, number>> {
// TODO: implement using squid GraphQL after v2 migration
return new Map();
},
};

View File

@@ -1,69 +0,0 @@
/** Ray-casting algorithm to test if a point is inside a polygon */
export function pointInPolygon(lat: number, lng: number, polygon: [number, number][]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [yi, xi] = polygon[i];
const [yj, xj] = polygon[j];
if ((yi > lat) !== (yj > lat) && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/** Format a number in French locale */
export const fr = (n: number, decimals = 2) =>
n.toLocaleString('fr-FR', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
export type CurrencyUnit = 'DU' | 'G1';
/** Format a G1 value in the given unit, with k/M suffix */
export function formatValue(g1Value: number, unit: CurrencyUnit, duDaily: number): string {
const val = unit === 'DU' ? g1Value / duDaily : g1Value;
const suffix = unit === 'DU' ? 'DU' : '\u011e1';
if (val >= 1_000_000) return fr(val / 1_000_000) + ' M' + suffix;
if (val >= 1_000) return fr(val / 1_000) + ' k' + suffix;
return fr(val) + ' ' + suffix;
}
/** Binary-search count of udBlocks entries >= joinBlock (udBlocks is sorted ascending). */
export function countUdSince(udBlocks: number[], joinBlock: number): number {
let lo = 0, hi = udBlocks.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (udBlocks[mid] < joinBlock) lo = mid + 1;
else hi = mid;
}
return udBlocks.length - lo;
}
/** Date to ISO-like string (yyyy-mm-dd) */
export const dateToString = (date: Date) =>
date.getFullYear() + '-' + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + ('0' + date.getDate()).slice(-2);
/** Number of days between a date and today */
export const getDays = (date: string | undefined) => {
if (!date) return 0;
const d = new Date(date);
const today = new Date();
return Math.floor(Math.abs(d.getTime() - today.getTime()) / (1000 * 3600 * 24));
};
/** Seniority ratio between two dates (days from today) */
export const getRatio = (date1: string | undefined, date2: string | undefined) => {
return getDays(date1) / Math.max(getDays(date2), 1);
};
export const Block0Date = '2017-03-08';
export type Friend = {
name: string;
date: string;
};
export type TableFriend = Friend & {
[key: string]: string | number;
displayName: string;
displayDate: string;
du: number;
};

View File

@@ -1,109 +0,0 @@
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ALPHABET_MAP = new Map<string, number>();
for (let i = 0; i < BASE58_ALPHABET.length; i++) {
ALPHABET_MAP.set(BASE58_ALPHABET[i], i);
}
export function base58Decode(str: string): Uint8Array {
if (str.length === 0) return new Uint8Array(0);
// Count leading '1's (zero bytes)
let leadingZeros = 0;
while (leadingZeros < str.length && str[leadingZeros] === '1') {
leadingZeros++;
}
// Decode base58 to big integer (stored as byte array)
const size = Math.ceil(str.length * 733 / 1000) + 1; // log(58) / log(256)
const bytes = new Uint8Array(size);
for (let i = leadingZeros; i < str.length; i++) {
const val = ALPHABET_MAP.get(str[i]);
if (val === undefined) throw new Error(`Invalid base58 character: ${str[i]}`);
let carry = val;
for (let j = size - 1; j >= 0; j--) {
carry += 256 * bytes[j];
bytes[j] = carry % 256;
carry = Math.floor(carry / 256);
}
}
// Skip leading zeros in the decoded bytes
let start = 0;
while (start < size && bytes[start] === 0) {
start++;
}
const result = new Uint8Array(leadingZeros + (size - start));
// Leading zeros from '1' characters
for (let i = 0; i < leadingZeros; i++) {
result[i] = 0;
}
// Decoded bytes
for (let i = start; i < size; i++) {
result[leadingZeros + (i - start)] = bytes[i];
}
return result;
}
export function base58Encode(bytes: Uint8Array): string {
if (bytes.length === 0) return '';
// Count leading zero bytes
let leadingZeros = 0;
while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) {
leadingZeros++;
}
// Encode to base58
const size = Math.ceil(bytes.length * 138 / 100) + 1; // log(256) / log(58)
const digits = new Uint8Array(size);
for (let i = leadingZeros; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = size - 1; j >= 0; j--) {
carry += 256 * digits[j];
digits[j] = carry % 58;
carry = Math.floor(carry / 58);
}
}
// Skip leading zeros in base58 output
let start = 0;
while (start < size && digits[start] === 0) {
start++;
}
let result = '1'.repeat(leadingZeros);
for (let i = start; i < size; i++) {
result += BASE58_ALPHABET[digits[i]];
}
return result;
}
/**
* Convert an SS58 address to a base58-encoded raw pubkey (Cesium+ v1 format).
*
* SS58 layout: [prefix (1 or 2 bytes)] [32 bytes pubkey] [2 bytes checksum]
* - If first byte has bit 6 set (& 0x40), prefix is 2 bytes
* - Otherwise prefix is 1 byte
*/
export function ss58ToV1Pubkey(ss58: string): string {
const raw = base58Decode(ss58);
// Determine prefix length
const prefixLen = (raw[0] & 0x40) ? 2 : 1;
// Extract 32-byte pubkey (skip prefix, drop 2-byte checksum)
const pubkey = raw.slice(prefixLen, prefixLen + 32);
if (pubkey.length !== 32) {
throw new Error(`Invalid SS58 address: expected 32-byte pubkey, got ${pubkey.length}`);
}
return base58Encode(pubkey);
}

BIN
media/Paroles Chansons.pdf Normal file

Binary file not shown.

View File

@@ -10,15 +10,12 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@geoman-io/leaflet-geoman-free": "^2.19.2",
"@nuxt/content": "^3.11.2", "@nuxt/content": "^3.11.2",
"@nuxt/image": "^2.0.0", "@nuxt/image": "^2.0.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@unocss/nuxt": "^66.6.0", "@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1", "@vueuse/nuxt": "^14.2.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"vue": "^3.5.28", "vue": "^3.5.28",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
@@ -27,7 +24,6 @@
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.91", "@iconify-json/lucide": "^1.2.91",
"@iconify-json/ph": "^1.2.2", "@iconify-json/ph": "^1.2.2",
"@types/leaflet": "^1.9.21",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unocss": "^66.6.0" "unocss": "^66.6.0"
}, },

273
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@geoman-io/leaflet-geoman-free':
specifier: ^2.19.2
version: 2.19.2(leaflet@1.9.4)
'@nuxt/content': '@nuxt/content':
specifier: ^3.11.2 specifier: ^3.11.2
version: 3.11.2(better-sqlite3@12.6.2)(magicast@0.5.2) version: 3.11.2(better-sqlite3@12.6.2)(magicast@0.5.2)
@@ -29,12 +26,6 @@ importers:
better-sqlite3: better-sqlite3:
specifier: ^12.6.2 specifier: ^12.6.2
version: 12.6.2 version: 12.6.2
leaflet:
specifier: ^1.9.4
version: 1.9.4
leaflet.markercluster:
specifier: ^1.5.3
version: 1.5.3(leaflet@1.9.4)
nuxt: nuxt:
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@25.2.3)(@vue/compiler-sfc@3.5.28)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.3)(magicast@0.5.2)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(yaml@2.8.2) version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@25.2.3)(@vue/compiler-sfc@3.5.28)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.3)(magicast@0.5.2)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(yaml@2.8.2)
@@ -54,9 +45,6 @@ importers:
'@iconify-json/ph': '@iconify-json/ph':
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2 version: 1.2.2
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@@ -400,12 +388,6 @@ packages:
'@fastify/accept-negotiator@2.0.1': '@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@geoman-io/leaflet-geoman-free@2.19.2':
resolution: {integrity: sha512-FYqLCFjCWLc1c5vel83i2ON77zPugH9qfxzLxTt+SiFiMgHjO1dSS59qz23aLLQ0hRWTQdycnxXGNmT+4OC9sg==}
engines: {node: '>=18.0.0'}
peerDependencies:
leaflet: ^1.2.0
'@iconify-json/lucide@1.2.91': '@iconify-json/lucide@1.2.91':
resolution: {integrity: sha512-8fuRiK+HiNRgCKMspn9UPsDpBw0TqVTIY0LOiDbMnFxOBwAulMXIl+SVOtp4LzxNvCXB5ofYffiiFIFDitqo7w==} resolution: {integrity: sha512-8fuRiK+HiNRgCKMspn9UPsDpBw0TqVTIY0LOiDbMnFxOBwAulMXIl+SVOtp4LzxNvCXB5ofYffiiFIFDitqo7w==}
@@ -1493,51 +1475,6 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@turf/bbox@7.3.4':
resolution: {integrity: sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==}
'@turf/boolean-contains@7.3.4':
resolution: {integrity: sha512-AJMGbtC6HiXgHvq0RNlTfsDB58Qf9Js45MP/APbhGTH4AiLZ8VMDISywVFNd7qN6oppNlDd3xApVR28+ti8bNg==}
'@turf/boolean-point-in-polygon@7.3.4':
resolution: {integrity: sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==}
'@turf/boolean-point-on-line@7.3.4':
resolution: {integrity: sha512-70gm5x6YQOZKcw0b/O4jjMwVWnFj+Zb6TXozLgZFDZShc8pgTQtZku7K+HKZ7Eya+7usHIB4IimZauomOMa+iw==}
'@turf/distance@7.3.4':
resolution: {integrity: sha512-9drWgd46uHPPyzgrcRQLgSvdS/SjVlQ6ZIBoRQagS5P2kSjUbcOXHIMeOSPwfxwlKhEtobLyr+IiR2ns1TfF8w==}
'@turf/geojson-rbush@7.3.4':
resolution: {integrity: sha512-aDG/5mMCgKduqBwZ3XpLOdlE2hizV3fM+5dHCWyrBepCQLeM/QRvvpBDCdQKDWKpoIBmrGGYDNiOofnf3QmGhg==}
'@turf/helpers@7.3.4':
resolution: {integrity: sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==}
'@turf/invariant@7.3.4':
resolution: {integrity: sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==}
'@turf/kinks@7.3.4':
resolution: {integrity: sha512-LZTKELWxvXl0vc9ZxVgi0v07fO9+2FrZOam2B10fz/eGjy3oKNazU5gjggbnc499wEIcJS4hN+VyjQZrmsJAdQ==}
'@turf/line-intersect@7.3.4':
resolution: {integrity: sha512-XygbTvHa6A+v6l2ZKYtS8AAWxwmrPxKxfBbdH75uED1JvdytSLWYTKGlcU3soxd9sYb4x/g9sDvRIVyU6Lucrg==}
'@turf/line-segment@7.3.4':
resolution: {integrity: sha512-UeISzf/JHoWEY5yeoyvKwA5epWcvJMCpCwbIMolvfTC5pp+IVozjHPVCRvRWuzmbmAvetcW0unL5bjqi0ADmuQ==}
'@turf/line-split@7.3.4':
resolution: {integrity: sha512-l1zmCSUnGsiN4gf22Aw91a2VnYs5DZS67FdkYqKgr+wPEAL/gpQgIBBWSTmhwY8zb3NEqty+f/gMEe8EJAWYng==}
'@turf/meta@7.3.4':
resolution: {integrity: sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==}
'@turf/nearest-point-on-line@7.3.4':
resolution: {integrity: sha512-DQrP3lRju83rIXFN68tUEpc7ki/eRwdwBkK2CTT4RAcyCxbcH2NGJPQv8dYiww/Ar77u1WLVn+aINXZH904dWw==}
'@turf/truncate@7.3.4':
resolution: {integrity: sha512-VPXdae9+RLLM19FMrJgt7QANBikm7DxPbfp/dXgzE4Ca7v+mJ4T1fYc7gCZDaqOrWMccHKbvv4iSuW7YZWdIIA==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1553,18 +1490,12 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -2020,9 +1951,6 @@ packages:
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
bindings@1.5.0: bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -3064,14 +2992,6 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'} engines: {node: '>= 0.6.3'}
leaflet.markercluster@1.5.3:
resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==}
peerDependencies:
leaflet: ^1.3.1
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
lilconfig@3.1.3: lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -3612,12 +3532,6 @@ packages:
pkg-types@2.3.0: pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
polyclip-ts@0.16.8:
resolution: {integrity: sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3833,9 +3747,6 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
radix3@1.1.2: radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
@@ -3846,9 +3757,6 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
rc9@2.1.2: rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
@@ -3966,9 +3874,6 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup-plugin-visualizer@6.0.5: rollup-plugin-visualizer@6.0.5:
resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4141,9 +4046,6 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
splaytree-ts@1.0.2:
resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==}
srvx@0.11.5: srvx@0.11.5:
resolution: {integrity: sha512-MbQgu/gbLcXjg1bhUhPXXOpeMfmDMTGSKPWeht5acXnlQNldD925eS4+bIH/qESecSkP71dU3Fmvunlai1+yzw==} resolution: {integrity: sha512-MbQgu/gbLcXjg1bhUhPXXOpeMfmDMTGSKPWeht5acXnlQNldD925eS4+bIH/qESecSkP71dU3Fmvunlai1+yzw==}
engines: {node: '>=20.16.0'} engines: {node: '>=20.16.0'}
@@ -4228,9 +4130,6 @@ packages:
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
sweepline-intersections@1.5.0:
resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==}
system-architecture@0.1.0: system-architecture@0.1.0:
resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4292,9 +4191,6 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyqueue@2.0.3:
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
to-buffer@1.2.2: to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5143,16 +5039,6 @@ snapshots:
'@fastify/accept-negotiator@2.0.1': '@fastify/accept-negotiator@2.0.1':
optional: true optional: true
'@geoman-io/leaflet-geoman-free@2.19.2(leaflet@1.9.4)':
dependencies:
'@turf/boolean-contains': 7.3.4
'@turf/kinks': 7.3.4
'@turf/line-intersect': 7.3.4
'@turf/line-split': 7.3.4
leaflet: 1.9.4
lodash: 4.17.23
polyclip-ts: 0.16.8
'@iconify-json/lucide@1.2.91': '@iconify-json/lucide@1.2.91':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -6232,123 +6118,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@turf/bbox@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/meta': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/boolean-contains@7.3.4':
dependencies:
'@turf/bbox': 7.3.4
'@turf/boolean-point-in-polygon': 7.3.4
'@turf/boolean-point-on-line': 7.3.4
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@turf/line-split': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/boolean-point-in-polygon@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@types/geojson': 7946.0.16
point-in-polygon-hao: 1.2.4
tslib: 2.8.1
'@turf/boolean-point-on-line@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/distance@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/geojson-rbush@7.3.4':
dependencies:
'@turf/bbox': 7.3.4
'@turf/helpers': 7.3.4
'@turf/meta': 7.3.4
'@types/geojson': 7946.0.16
rbush: 3.0.1
tslib: 2.8.1
'@turf/helpers@7.3.4':
dependencies:
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/invariant@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/kinks@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/line-intersect@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@types/geojson': 7946.0.16
sweepline-intersections: 1.5.0
tslib: 2.8.1
'@turf/line-segment@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@turf/meta': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/line-split@7.3.4':
dependencies:
'@turf/bbox': 7.3.4
'@turf/geojson-rbush': 7.3.4
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@turf/line-intersect': 7.3.4
'@turf/line-segment': 7.3.4
'@turf/meta': 7.3.4
'@turf/nearest-point-on-line': 7.3.4
'@turf/truncate': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/meta@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/nearest-point-on-line@7.3.4':
dependencies:
'@turf/distance': 7.3.4
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@turf/meta': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/truncate@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/meta': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6370,18 +6139,12 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.4': '@types/hast@3.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -7030,8 +6793,6 @@ snapshots:
bindings: 1.5.0 bindings: 1.5.0
prebuild-install: 7.1.3 prebuild-install: 7.1.3
bignumber.js@9.3.1: {}
bindings@1.5.0: bindings@1.5.0:
dependencies: dependencies:
file-uri-to-path: 1.0.0 file-uri-to-path: 1.0.0
@@ -8154,12 +7915,6 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
leaflet.markercluster@1.5.3(leaflet@1.9.4):
dependencies:
leaflet: 1.9.4
leaflet@1.9.4: {}
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
listhen@1.9.0: listhen@1.9.0:
@@ -9131,15 +8886,6 @@ snapshots:
exsolve: 1.0.8 exsolve: 1.0.8
pathe: 2.0.3 pathe: 2.0.3
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.2
polyclip-ts@0.16.8:
dependencies:
bignumber.js: 9.3.1
splaytree-ts: 1.0.2
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-calc@10.1.1(postcss@8.5.6): postcss-calc@10.1.1(postcss@8.5.6):
@@ -9345,8 +9091,6 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
quickselect@2.0.0: {}
radix3@1.1.2: {} radix3@1.1.2: {}
randombytes@2.1.0: randombytes@2.1.0:
@@ -9355,10 +9099,6 @@ snapshots:
range-parser@1.2.1: {} range-parser@1.2.1: {}
rbush@3.0.1:
dependencies:
quickselect: 2.0.0
rc9@2.1.2: rc9@2.1.2:
dependencies: dependencies:
defu: 6.1.4 defu: 6.1.4
@@ -9554,8 +9294,6 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
robust-predicates@3.0.2: {}
rollup-plugin-visualizer@6.0.5(rollup@4.57.1): rollup-plugin-visualizer@6.0.5(rollup@4.57.1):
dependencies: dependencies:
open: 8.4.2 open: 8.4.2
@@ -9797,8 +9535,6 @@ snapshots:
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
splaytree-ts@1.0.2: {}
srvx@0.11.5: {} srvx@0.11.5: {}
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
@@ -9887,10 +9623,6 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
sax: 1.4.4 sax: 1.4.4
sweepline-intersections@1.5.0:
dependencies:
tinyqueue: 2.0.3
system-architecture@0.1.0: {} system-architecture@0.1.0: {}
tagged-tag@1.0.0: {} tagged-tag@1.0.0: {}
@@ -9960,8 +9692,6 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinyqueue@2.0.3: {}
to-buffer@1.2.2: to-buffer@1.2.2:
dependencies: dependencies:
isarray: 2.0.5 isarray: 2.0.5
@@ -9984,7 +9714,8 @@ snapshots:
trough@2.2.0: {} trough@2.2.0: {}
tslib@2.8.1: {} tslib@2.8.1:
optional: true
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
dependencies: dependencies:

View File

@@ -17,7 +17,7 @@ footer:
- label: Mentions légales - label: Mentions légales
to: /mentions-legales to: /mentions-legales
gratewizard: gratewizard:
url: /gratewizard?popup url: https://gratewizard.ml
popup: popup:
width: 420 width: 420
height: 720 height: 720

View File

@@ -74,7 +74,7 @@ export default defineConfig({
shortcuts: { shortcuts: {
'btn-primary': 'inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-display font-semibold tracking-wide transition-all duration-200 hover:bg-primary-600 hover:scale-105 active:scale-95', 'btn-primary': 'inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-display font-semibold tracking-wide transition-all duration-200 hover:bg-primary-600 hover:scale-105 active:scale-95',
'btn-accent': 'inline-flex items-center justify-center px-6 py-3 rounded-lg bg-accent text-surface-bg font-display font-semibold tracking-wide transition-all duration-200 hover:bg-accent-600 hover:scale-105 active:scale-95', 'btn-accent': 'inline-flex items-center justify-center px-6 py-3 rounded-lg bg-accent text-surface-bg font-display font-semibold tracking-wide transition-all duration-200 hover:bg-accent-600 hover:scale-105 active:scale-95',
'btn-ghost': 'inline-flex items-center justify-center px-4 py-2 rounded-lg text-white/70 font-sans transition-all duration-200 hover:bg-white/10 hover:text-white', 'btn-ghost': 'inline-flex items-center justify-center px-4 py-2 rounded-lg border-none text-white/70 font-sans transition-all duration-200 hover:bg-white/10 hover:text-white',
'card-surface': 'rounded-xl bg-surface border border-white/8 p-6 transition-all duration-300 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5', 'card-surface': 'rounded-xl bg-surface border border-white/8 p-6 transition-all duration-300 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5',
'text-gradient': 'bg-gradient-to-r from-primary-300 to-accent bg-clip-text text-transparent', 'text-gradient': 'bg-gradient-to-r from-primary-300 to-accent bg-clip-text text-transparent',
'text-muted': 'text-white/60', 'text-muted': 'text-white/60',