feat: global command palette and scroll/layer context

This commit is contained in:
SkateSpots 2026-10-28 18:08:45 +07:00
parent 245a59bc31
commit 534e939f9e
33 changed files with 893 additions and 13 deletions

View file

@ -0,0 +1,339 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Compass,
Hammer,
Heart,
Info,
KeyRound,
MapPin,
Package,
Radio,
Search,
Settings,
Shield,
ShoppingBag,
Users,
type LucideIcon,
} from 'lucide-react';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useShops } from '@/hooks/useShops';
import { useCrews } from '@/hooks/useCrews';
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useSkaters } from '@/hooks/useSkaters';
import { NAV_CATALOG } from '@/hooks/useNavPreferences';
import { nip19 } from 'nostr-tools';
import { GLOBAL_SEARCH_EVENT } from '@/lib/globalSearch';
const RECENT_KEY = 'skatespots:global-search-recent';
interface SearchResult {
id: string;
type: string;
group: 'recent' | 'pages' | 'spots' | 'shops' | 'crews' | 'events' | 'gear' | 'skaters';
title: string;
subtitle?: string;
to: string;
icon: LucideIcon;
}
interface RecentResult {
type: string;
title: string;
to: string;
}
const STATIC_PAGES: Array<{ id: string; href: string; labelKey: string; icon: LucideIcon; authOnly?: boolean }> = [
{ id: 'map', href: '/', labelKey: 'nav.map', icon: MapPin },
{ id: 'settings', href: '/settings', labelKey: 'nav.settings', icon: Settings },
{ id: 'messages', href: '/messages', labelKey: 'nav.messages', icon: Compass, authOnly: true },
{ id: 'newsletter', href: '/newsletter', labelKey: 'nav.newsletter', icon: Radio },
{ id: 'community', href: '/community', labelKey: 'communityUi.link', icon: Users },
{ id: 'app-shop', href: '/app-shop', labelKey: 'nav.appShop', icon: ShoppingBag },
{ id: 'diy', href: '/diy', labelKey: 'nav.diy', icon: Hammer },
{ id: 'signers', href: '/signers', labelKey: 'globalSearchUi.signers', icon: KeyRound },
{ id: 'trust', href: '/trust', labelKey: 'globalSearchUi.trust', icon: Shield },
{ id: 'privacy', href: '/privacy', labelKey: 'chromeUi.privacy', icon: Shield },
{ id: 'about', href: '/about', labelKey: 'nav.about', icon: Info },
{ id: 'license', href: '/license', labelKey: 'chromeUi.license', icon: Info },
];
function readRecent(): RecentResult[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is RecentResult =>
!!item &&
typeof item === 'object' &&
typeof (item as RecentResult).title === 'string' &&
typeof (item as RecentResult).to === 'string' &&
typeof (item as RecentResult).type === 'string',
).slice(0, 6);
} catch {
return [];
}
}
function writeRecent(result: SearchResult): RecentResult[] {
const next: RecentResult[] = [
{ type: result.type, title: result.title, to: result.to },
...readRecent().filter((item) => item.to !== result.to),
].slice(0, 6);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
// storage is best-effort
}
return next;
}
function includes(query: string, ...values: Array<string | undefined>): boolean {
if (!query) return true;
return values.some((value) => (value ?? '').toLowerCase().includes(query));
}
export function GlobalSearch() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [recent, setRecent] = useState<RecentResult[]>(readRecent);
// These queries only run while the palette is open. The map's hotter keys are
// reused when the user opens search from the map, so no duplicate relay work.
const spots = useSkateSpots(2000, { enabled: open });
const shops = useShops(500, { enabled: open });
const crews = useCrews(200, { enabled: open });
const events = useUpcomingSkateEvents(100, { enabled: open });
const gear = useMarketplaceItems(200, { enabled: open });
const skaters = useSkaters(200, { enabled: open });
useEffect(() => {
const onOpen = () => setOpen(true);
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
setOpen(true);
}
};
window.addEventListener(GLOBAL_SEARCH_EVENT, onOpen);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener(GLOBAL_SEARCH_EVENT, onOpen);
document.removeEventListener('keydown', onKey);
};
}, []);
useEffect(() => {
if (open) setQuery('');
}, [open]);
const select = useCallback((result: SearchResult) => {
setRecent(writeRecent(result));
setOpen(false);
navigate(result.to);
}, [navigate]);
const results = useMemo<SearchResult[]>(() => {
const q = query.trim().toLowerCase();
const all: SearchResult[] = [];
const authOk = (authOnly?: boolean) => !authOnly || !!user;
for (const page of STATIC_PAGES) {
if (!authOk(page.authOnly)) continue;
const title = t(page.labelKey);
if (!includes(q, title, page.href)) continue;
all.push({ id: `page:${page.id}`, type: 'page', group: 'pages', title, to: page.href, icon: page.icon });
}
for (const page of NAV_CATALOG) {
if (!authOk(page.authOnly)) continue;
const title = page.labelKey ? t(page.labelKey) : page.label;
if (!includes(q, title, page.label, page.href)) continue;
if (all.some((item) => item.to === page.href)) continue;
all.push({ id: `nav:${page.id}`, type: 'page', group: 'pages', title, to: page.href, icon: page.icon });
}
for (const spot of (spots.data ?? [])) {
if (!includes(q, spot.title, spot.location, spot.category)) continue;
all.push({
id: `spot:${spot.id}`,
type: 'spot',
group: 'spots',
title: spot.title,
subtitle: [spot.category, spot.location].filter(Boolean).join(' · '),
to: `/spot/${spot.pubkey}/${spot.identifier}`,
icon: MapPin,
});
}
for (const shop of (shops.data ?? [])) {
if (!includes(q, shop.name, shop.location, shop.description)) continue;
all.push({
id: `shop:${shop.id}`,
type: 'shop',
group: 'shops',
title: shop.name,
subtitle: shop.location,
to: `/shop/${shop.pubkey}/${shop.identifier}`,
icon: ShoppingBag,
});
}
for (const crew of (crews.data ?? [])) {
if (!includes(q, crew.name, crew.location, crew.bio)) continue;
all.push({
id: `crew:${crew.id}`,
type: 'crew',
group: 'crews',
title: crew.name,
subtitle: crew.location,
to: `/crew/${crew.pubkey}/${crew.identifier}`,
icon: Users,
});
}
for (const event of (events.data ?? [])) {
if (!includes(q, event.title, event.location, event.description)) continue;
all.push({
id: `event:${event.id}`,
type: 'event',
group: 'events',
title: event.title,
subtitle: [event.location, new Date(event.start * 1000).toLocaleDateString()].filter(Boolean).join(' · '),
to: `/event/${event.pubkey}/${event.identifier}`,
icon: Calendar,
});
}
for (const item of (gear.data ?? [])) {
if (!includes(q, item.title, item.summary, item.description, item.condition)) continue;
all.push({
id: `gear:${item.id}`,
type: 'gear',
group: 'gear',
title: item.title,
subtitle: item.price ? `${item.price.amount} ${item.price.currency}` : undefined,
to: `/listing/${item.pubkey}/${item.identifier}`,
icon: Package,
});
}
for (const skater of (skaters.data ?? [])) {
if (!includes(q, skater.title, skater.location, skater.bio, skater.homeSpot)) continue;
let to = '/skaters';
try { to = `/${nip19.npubEncode(skater.pubkey)}`; } catch { /* ignore */ }
all.push({
id: `skater:${skater.id}`,
type: 'skater',
group: 'skaters',
title: skater.title,
subtitle: skater.location,
to,
icon: Heart,
});
}
if (!q) {
const recentResults: SearchResult[] = recent.map((item) => ({
...item,
id: `recent:${item.to}`,
group: 'recent',
icon: item.type === 'spot' ? MapPin : item.type === 'crew' ? Users : item.type === 'event' ? Calendar : item.type === 'gear' ? Package : item.type === 'skater' ? Heart : Search,
}));
const pages = all.filter((item) => item.group === 'pages');
return [...recentResults, ...pages];
}
const grouped: Record<string, SearchResult[]> = {};
const seen = new Set<string>();
for (const item of all) {
// Addressable relays can return more than one version of the same item;
// keep the first (the hooks already sort newest-first). Also collapse
// exact title/subtitle twins from cloned test pins.
const signature = `${item.type}|${item.title}|${item.subtitle ?? ''}`;
if (seen.has(item.to) || seen.has(signature)) continue;
seen.add(item.to);
seen.add(signature);
(grouped[item.group] ??= []).push(item);
}
return ['pages', 'spots', 'crews', 'shops', 'events', 'gear', 'skaters']
.flatMap((group) => (grouped[group] ?? []).slice(0, 5));
}, [query, user, recent, spots.data, shops.data, crews.data, events.data, gear.data, skaters.data, t]);
const grouped = useMemo(() => {
const map = new Map<string, SearchResult[]>();
for (const item of results) {
const list = map.get(item.group) ?? [];
list.push(item);
map.set(item.group, list);
}
return map;
}, [results]);
const groupLabel = (group: string) => {
switch (group) {
case 'recent': return t('globalSearchUi.recent');
case 'pages': return t('globalSearchUi.pages');
case 'spots': return t('globalSearchUi.spots');
case 'shops': return t('globalSearchUi.shops');
case 'crews': return t('globalSearchUi.crews');
case 'events': return t('globalSearchUi.events');
case 'gear': return t('globalSearchUi.gear');
case 'skaters': return t('globalSearchUi.skaters');
default: return group;
}
};
const loading = open && [spots, shops, crews, events, gear, skaters].some((query) => query.isLoading);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={t('globalSearchUi.placeholder')}
autoFocus
/>
<CommandList>
{!loading && results.length === 0 && (
<CommandEmpty>{t('globalSearchUi.noResults', { query: query.trim() })}</CommandEmpty>
)}
{[...grouped.entries()].map(([group, items]) => (
<CommandGroup key={group} heading={groupLabel(group)}>
{items.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={`${group}:${item.id}`}
value={`${group} ${item.title} ${item.subtitle ?? ''}`}
onSelect={() => select(item)}
className="gap-2"
>
<Icon className="h-4 w-4 shrink-0 text-teal-300" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-white">{item.title}</span>
{item.subtitle && <span className="block truncate text-[11px] text-gray-500">{item.subtitle}</span>}
</span>
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
<div className="border-t border-white/10 px-3 py-2 text-[10px] text-gray-600">
{t('globalSearchUi.hint')}
</div>
</CommandDialog>
);
}

View file

@ -52,6 +52,7 @@ import {
MessagesSquare, MessagesSquare,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -60,6 +61,8 @@ import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay'; import { NewsletterOverlay } from '@/components/NewsletterOverlay';
import { NotificationBellLazy } from '@/components/NotificationBellLazy'; import { NotificationBellLazy } from '@/components/NotificationBellLazy';
import { GlobalSearch } from '@/components/GlobalSearch';
import { openGlobalSearch, GLOBAL_SEARCH_EVENT } from '@/lib/globalSearch';
import { OsNotificationBridge } from '@/components/OsNotificationBridge'; import { OsNotificationBridge } from '@/components/OsNotificationBridge';
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why // Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
@ -537,6 +540,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
const WELCOME_LOGIN_SNOOZED = 'skatespots:desktop-welcome-snoozed'; const WELCOME_LOGIN_SNOOZED = 'skatespots:desktop-welcome-snoozed';
const [showWelcomeLogin, setShowWelcomeLogin] = useState(false); const [showWelcomeLogin, setShowWelcomeLogin] = useState(false);
const [showWelcomeSignup, setShowWelcomeSignup] = useState(false); const [showWelcomeSignup, setShowWelcomeSignup] = useState(false);
const [searchOpened, setSearchOpened] = useState(false);
const dismissWelcomeLogin = () => { const dismissWelcomeLogin = () => {
try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ } try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ }
setShowWelcomeLogin(false); setShowWelcomeLogin(false);
@ -555,14 +559,25 @@ export function Layout({ children, noPadding }: LayoutProps) {
const requestLogin = () => requestAuth('login'); const requestLogin = () => requestAuth('login');
const requestSignup = () => requestAuth('signup'); const requestSignup = () => requestAuth('signup');
useEffect(() => { useEffect(() => {
if (user || authDialog || isNativePlatform()) return; const onSearchOpen = () => {
setSearchOpened(true);
setShowWelcomeLogin(false);
setShowWelcomeSignup(false);
try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ }
};
window.addEventListener(GLOBAL_SEARCH_EVENT, onSearchOpen);
return () => window.removeEventListener(GLOBAL_SEARCH_EVENT, onSearchOpen);
}, []);
useEffect(() => {
if (user || authDialog || isNativePlatform() || searchOpened) return;
try { try {
if (sessionStorage.getItem(WELCOME_LOGIN_SNOOZED) === '1') return; if (sessionStorage.getItem(WELCOME_LOGIN_SNOOZED) === '1') return;
} catch { return; } } catch { return; }
if (typeof window === 'undefined' || !window.matchMedia?.('(min-width: 1024px)').matches) return; if (typeof window === 'undefined' || !window.matchMedia?.('(min-width: 1024px)').matches) return;
const timer = window.setTimeout(() => setShowWelcomeLogin(true), 900); const timer = window.setTimeout(() => setShowWelcomeLogin(true), 900);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [user, authDialog]); }, [user, authDialog, searchOpened]);
const { t, language } = useTranslation(); const { t, language } = useTranslation();
const currentLanguage = LANGUAGE_OPTIONS.find((item) => item.code === language) ?? LANGUAGE_OPTIONS[0]; const currentLanguage = LANGUAGE_OPTIONS.find((item) => item.code === language) ?? LANGUAGE_OPTIONS[0];
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts(); const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
@ -780,6 +795,16 @@ export function Layout({ children, noPadding }: LayoutProps) {
<span className="text-sm font-bold">{t('chromeUi.status')}</span> <span className="text-sm font-bold">{t('chromeUi.status')}</span>
</Button> </Button>
)} )}
<Button
variant="ghost"
size="icon"
className="text-gray-300 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
onClick={openGlobalSearch}
title={t('globalSearchUi.open')}
aria-label={t('globalSearchUi.open')}
>
<Search className="h-5 w-5" />
</Button>
{/* Notifications (crew invites & membership requests) */} {/* Notifications (crew invites & membership requests) */}
{user && <NotificationBellLazy />} {user && <NotificationBellLazy />}
{/* Offline Indicator */} {/* Offline Indicator */}
@ -824,6 +849,16 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Link> </Link>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="text-gray-300 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
onClick={openGlobalSearch}
title={t('globalSearchUi.open')}
aria-label={t('globalSearchUi.open')}
>
<Search className="h-5 w-5" />
</Button>
{/* Notifications (crew invites & membership requests) */} {/* Notifications (crew invites & membership requests) */}
{user && <NotificationBellLazy />} {user && <NotificationBellLazy />}
{/* User menu when logged in / Login button when not. {/* User menu when logged in / Login button when not.
@ -1292,6 +1327,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
/> />
</Suspense> </Suspense>
{/* Newsletter: floating pill + Monday admin reminder */}
<GlobalSearch />
{/* Newsletter: floating pill + Monday admin reminder */} {/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay /> <NewsletterOverlay />

View file

@ -1,12 +1,85 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation, useNavigationType } from 'react-router-dom';
const KEY = 'skatespots:scroll-positions';
const MAX = 80;
interface SavedPosition {
path: string;
y: number;
at: number;
}
function readPositions(): Record<string, SavedPosition> {
try {
const raw = sessionStorage.getItem(KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, SavedPosition> : {};
} catch {
return {};
}
}
function writePositions(positions: Record<string, SavedPosition>): void {
try {
const entries = Object.entries(positions)
.sort(([, a], [, b]) => b.at - a.at)
.slice(0, MAX);
sessionStorage.setItem(KEY, JSON.stringify(Object.fromEntries(entries)));
} catch {
// storage may be unavailable
}
}
/**
* Scroll behaviour:
* - new navigation starts at the top;
* - browser Back/Forward restores the saved position for that history entry;
* - query/tab changes on the same page keep the current scroll.
*/
export function ScrollToTop() { export function ScrollToTop() {
const { pathname } = useLocation(); const location = useLocation();
const navigationType = useNavigationType();
const previousPath = useRef(location.pathname);
const raf = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0); const positions = readPositions();
}, [pathname]); const pathChanged = previousPath.current !== location.pathname;
previousPath.current = location.pathname;
if (!pathChanged) return;
if (navigationType === 'POP') {
const saved = positions[location.key]?.y ?? 0;
requestAnimationFrame(() => window.scrollTo(0, saved));
} else {
window.scrollTo(0, 0);
}
}, [location.key, location.pathname, navigationType]);
useEffect(() => {
const save = () => {
if (raf.current !== null) return;
raf.current = window.requestAnimationFrame(() => {
raf.current = null;
const positions = readPositions();
positions[location.key] = {
path: location.pathname,
y: window.scrollY,
at: Date.now(),
};
writePositions(positions);
});
};
window.addEventListener('scroll', save, { passive: true });
return () => {
window.removeEventListener('scroll', save);
if (raf.current !== null) window.cancelAnimationFrame(raf.current);
raf.current = null;
};
}, [location.key, location.pathname]);
return null; return null;
} }

View file

@ -18,8 +18,8 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo
* you just hid vanishes from the list, so there is no way to undo the verdict. * you just hid vanishes from the list, so there is no way to undo the verdict.
* Normal callers must leave it off so hidden spots stay hidden. * Normal callers must leave it off so hidden spots stay hidden.
*/ */
export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) { export function useSkateSpots(limit = 100, options: { includeHidden?: boolean; enabled?: boolean } = {}) {
const { includeHidden = false } = options; const { includeHidden = false, enabled = true } = options;
// The map is CANONICAL: spots come from the community relay only, so what is // The map is CANONICAL: spots come from the community relay only, so what is
// shown always matches the community dataset regardless of the viewer's // shown always matches the community dataset regardless of the viewer's
// personal relays. (Details/profile pages stay blended via useSkateSpot.) // personal relays. (Details/profile pages stay blended via useSkateSpot.)
@ -28,6 +28,7 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean }
return useQuery({ return useQuery({
queryKey: ['skate-spots', limit, includeHidden, 'canonical'], queryKey: ['skate-spots', limit, includeHidden, 'canonical'],
enabled,
select: (spots: SkateSpot[]) => select: (spots: SkateSpot[]) =>
includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
queryFn: async () => { queryFn: async () => {

View file

@ -11,12 +11,14 @@ const SKATER_D = 'skater-profile';
* plain kind-0 Nostr profiles never appear here, only opted-in app skaters. * plain kind-0 Nostr profiles never appear here, only opted-in app skaters.
* Deduplicated per author (newest wins), newest-first. * Deduplicated per author (newest wins), newest-first.
*/ */
export function useSkaters(limit = 200) { export function useSkaters(limit = 200, options: { enabled?: boolean } = {}) {
const { enabled = true } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration(); const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['skaters', limit, getQueryKeySuffix()], queryKey: ['skaters', limit, getQueryKeySuffix()],
enabled,
select: (skaters: Skater[]) => filterAdminHidden(skaters, SKATE_SKATER_KIND, hidden), select: (skaters: Skater[]) => filterAdminHidden(skaters, SKATE_SKATER_KIND, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);

View file

@ -1356,6 +1356,22 @@ const batch1 = {
body: "Amber demana aprovació a cada acció signada. Les lectures i els desxifrats en segon pla són locals i no mostren avisos. Enviar un DM de grup demana un cop per destinatari (xifrar + signar), i Cordn pot demanar a cada sol·licitud del coordinador. Per a xats privats o de colla freqüents, un bunker o una clau local és més silenciós.", body: "Amber demana aprovació a cada acció signada. Les lectures i els desxifrats en segon pla són locals i no mostren avisos. Enviar un DM de grup demana un cop per destinatari (xifrar + signar), i Cordn pot demanar a cada sol·licitud del coordinador. Per a xats privats o de colla freqüents, un bunker o una clau local és més silenciós.",
bunker: "Deixa Amber per a signatures importants, o canvia a un bunker quan esperis una conversa llarga.", bunker: "Deixa Amber per a signatures importants, o canvia a un bunker quan esperis una conversa llarga.",
}, },
globalSearchUi: {
open: "Cerca",
placeholder: "Cerca spots, colles, esdeveniments, material…",
hint: "⌘K / Ctrl+K",
recent: "Recents",
pages: "Pàgines",
spots: "Spots",
shops: "Botigues",
crews: "Colles",
events: "Esdeveniments",
gear: "Material",
skaters: "Skaters",
signers: "Signadors",
trust: "Confiança",
noResults: "Cap resultat per a «{{query}}».",
},
}; };
export default batch1; export default batch1;

View file

@ -431,6 +431,22 @@ const drift = {
body: "Amber žádá o schválení u každé podepsané akce. Čtení a dešifrování na pozadí zůstávají lokální a nevyvolávají výzvy. Odeslání skupinové zprávy vyžádá schválení jednou za každého příjemce (šifrování + podpis) a Cordn může žádat u každého požadavku koordinátora. U častého soukromého chatu nebo chatu crew je tišší bunker nebo místní klíč.", body: "Amber žádá o schválení u každé podepsané akce. Čtení a dešifrování na pozadí zůstávají lokální a nevyvolávají výzvy. Odeslání skupinové zprávy vyžádá schválení jednou za každého příjemce (šifrování + podpis) a Cordn může žádat u každého požadavku koordinátora. U častého soukromého chatu nebo chatu crew je tišší bunker nebo místní klíč.",
bunker: "Nech Amber na podepisování důležitých věcí, nebo přejdi na bunker, když čekáš dlouhý chat.", bunker: "Nech Amber na podepisování důležitých věcí, nebo přejdi na bunker, když čekáš dlouhý chat.",
}, },
globalSearchUi: {
open: "Hledat",
placeholder: "Hledej spoty, crew, akce, vybavení…",
hint: "⌘K / Ctrl+K",
recent: "Nedávné",
pages: "Stránky",
spots: "Spoty",
shops: "Obchody",
crews: "Crews",
events: "Akce",
gear: "Vybavení",
skaters: "Skejťáci",
signers: "Podepisovači",
trust: "Důvěra",
noResults: "Žádné výsledky pro „{{query}}“.",
},
}; };
export default drift; export default drift;

View file

@ -433,6 +433,22 @@ const drift = {
body: "Amber fragt bei jeder signierten Aktion nach einer Bestätigung. Lesen und Entschlüsseln im Hintergrund bleiben lokal und lösen keine Abfrage aus. Beim Senden einer Gruppen-DM wird einmal pro Empfänger gefragt (Verschlüsseln + Signieren), und Cordn kann bei jeder Koordinator-Anfrage fragen. Für häufige private Chats oder Crew-Chats ist ein Bunker oder ein lokaler Schlüssel ruhiger.", body: "Amber fragt bei jeder signierten Aktion nach einer Bestätigung. Lesen und Entschlüsseln im Hintergrund bleiben lokal und lösen keine Abfrage aus. Beim Senden einer Gruppen-DM wird einmal pro Empfänger gefragt (Verschlüsseln + Signieren), und Cordn kann bei jeder Koordinator-Anfrage fragen. Für häufige private Chats oder Crew-Chats ist ein Bunker oder ein lokaler Schlüssel ruhiger.",
bunker: "Behalte Amber für das Signieren wichtiger Aktionen oder wechsle zu einem Bunker, wenn du einen langen Chat erwartest.", bunker: "Behalte Amber für das Signieren wichtiger Aktionen oder wechsle zu einem Bunker, wenn du einen langen Chat erwartest.",
}, },
globalSearchUi: {
open: "Suchen",
placeholder: "Spots, Crews, Events, Gear suchen…",
hint: "⌘K / Ctrl+K",
recent: "Zuletzt",
pages: "Seiten",
spots: "Spots",
shops: "Shops",
crews: "Crews",
events: "Events",
gear: "Gear",
skaters: "Skaters",
signers: "Signer",
trust: "Vertrauen",
noResults: "Keine Ergebnisse für „{{query}}“.",
},
}; };
export default drift; export default drift;

View file

@ -435,6 +435,22 @@ const drift = {
body: "Το Amber ζητά έγκριση για κάθε υπογεγραμμένη ενέργεια. Οι αναγνώσεις και οι αποκρυπτογραφήσεις στο παρασκήνιο μένουν τοπικές και δεν προκαλούν ειδοποιήσεις. Η αποστολή ομαδικού DM ζητά έγκριση μία φορά ανά παραλήπτη (κρυπτογράφηση + υπογραφή) και το Cordn μπορεί να ζητά για κάθε αίτημα του συντονιστή. Για συχνό ιδιωτικό chat ή chat του crew, ένα bunker ή ένα τοπικό κλειδί είναι πιο ήσυχο.", body: "Το Amber ζητά έγκριση για κάθε υπογεγραμμένη ενέργεια. Οι αναγνώσεις και οι αποκρυπτογραφήσεις στο παρασκήνιο μένουν τοπικές και δεν προκαλούν ειδοποιήσεις. Η αποστολή ομαδικού DM ζητά έγκριση μία φορά ανά παραλήπτη (κρυπτογράφηση + υπογραφή) και το Cordn μπορεί να ζητά για κάθε αίτημα του συντονιστή. Για συχνό ιδιωτικό chat ή chat του crew, ένα bunker ή ένα τοπικό κλειδί είναι πιο ήσυχο.",
bunker: "Κράτησε το Amber για υπογραφές υψηλής αξίας ή πήγαινε σε bunker όταν περιμένεις μεγάλη συζήτηση.", bunker: "Κράτησε το Amber για υπογραφές υψηλής αξίας ή πήγαινε σε bunker όταν περιμένεις μεγάλη συζήτηση.",
}, },
globalSearchUi: {
open: "Αναζήτηση",
placeholder: "Αναζήτηση spot, crew, εκδηλώσεων, εξοπλισμού…",
hint: "⌘K / Ctrl+K",
recent: "Πρόσφατα",
pages: "Σελίδες",
spots: "Spots",
shops: "Καταστήματα",
crews: "Crews",
events: "Εκδηλώσεις",
gear: "Εξοπλισμός",
skaters: "Skaters",
signers: "Signers",
trust: "Εμπιστοσύνη",
noResults: "Κανένα αποτέλεσμα για «{{query}}».",
},
}; };
export default drift; export default drift;

View file

@ -1356,6 +1356,22 @@ const batch1 = {
body: "Amber-ek onarpena eskatzen du sinatutako ekintza bakoitzean. Irakurketak eta atzeko planoko deszifratzeak lokalean egiten dira eta ez dute abisurik erakusten. Taldeko DM bat bidaltzean hartzaile bakoitzeko behin eskatzen du (zifratzea + sinatzea), eta Cordn-ek koordinatzailearen eskaera bakoitzean galdetu dezake. Sarritan erabiltzen diren txat pribatu edo taldeko txatetarako, bunker bat edo gako lokal bat isilagoa da.", body: "Amber-ek onarpena eskatzen du sinatutako ekintza bakoitzean. Irakurketak eta atzeko planoko deszifratzeak lokalean egiten dira eta ez dute abisurik erakusten. Taldeko DM bat bidaltzean hartzaile bakoitzeko behin eskatzen du (zifratzea + sinatzea), eta Cordn-ek koordinatzailearen eskaera bakoitzean galdetu dezake. Sarritan erabiltzen diren txat pribatu edo taldeko txatetarako, bunker bat edo gako lokal bat isilagoa da.",
bunker: "Utzi Amber balio handiko sinaduretara, edo aldatu bunker batera elkarrizketa luzea espero baduzu.", bunker: "Utzi Amber balio handiko sinaduretara, edo aldatu bunker batera elkarrizketa luzea espero baduzu.",
}, },
globalSearchUi: {
open: "Bilatu",
placeholder: "Bilatu tokiak, taldeak, ekitaldiak, ekipamendua…",
hint: "⌘K / Ctrl+K",
recent: "Berrienak",
pages: "Orriak",
spots: "Tokiak",
shops: "Dendak",
crews: "Taldeak",
events: "Ekitaldiak",
gear: "Ekipamendua",
skaters: "Skaterrak",
signers: "Sinatzaileak",
trust: "Konfiantza",
noResults: "Ez dago emaitzarik «{{query}}» bilaketarako.",
},
}; };
export default batch1; export default batch1;

View file

@ -433,6 +433,22 @@ const drift = {
body: "Amber demande une approbation pour chaque action signée. Les lectures et les déchiffrements en arrière-plan restent locaux et ne déclenchent aucune invite. L'envoi d'un DM de groupe demande une fois par destinataire (chiffrement + signature), et Cordn peut demander à chaque requête de coordinateur. Pour un chat privé ou d'équipage fréquent, un bunker ou une clé locale est plus discret.", body: "Amber demande une approbation pour chaque action signée. Les lectures et les déchiffrements en arrière-plan restent locaux et ne déclenchent aucune invite. L'envoi d'un DM de groupe demande une fois par destinataire (chiffrement + signature), et Cordn peut demander à chaque requête de coordinateur. Pour un chat privé ou d'équipage fréquent, un bunker ou une clé locale est plus discret.",
bunker: "Garde Amber pour les signatures importantes, ou passe à un bunker si tu prévois une longue conversation.", bunker: "Garde Amber pour les signatures importantes, ou passe à un bunker si tu prévois une longue conversation.",
}, },
globalSearchUi: {
open: "Rechercher",
placeholder: "Rechercher spots, crews, événements, matériel…",
hint: "⌘K / Ctrl+K",
recent: "Récents",
pages: "Pages",
spots: "Spots",
shops: "Skateshops",
crews: "Crews",
events: "Événements",
gear: "Matériel",
skaters: "Skaters",
signers: "Signataires",
trust: "Confiance",
noResults: "Aucun résultat pour « {{query}} ».",
},
}; };
export default drift; export default drift;

View file

@ -1356,6 +1356,22 @@ const batch1 = {
body: "Amber pide aprobación en cada acción asinada. As lecturas e os descifrados en segundo plano son locais e non amosan avisos. Enviar un DM de grupo pide unha vez por destinatario (cifrar + asinar), e Cordn pode pedir en cada solicitude do coordinador. Para chats privados ou de grupo frecuentes, un bunker ou unha chave local é máis silencioso.", body: "Amber pide aprobación en cada acción asinada. As lecturas e os descifrados en segundo plano son locais e non amosan avisos. Enviar un DM de grupo pide unha vez por destinatario (cifrar + asinar), e Cordn pode pedir en cada solicitude do coordinador. Para chats privados ou de grupo frecuentes, un bunker ou unha chave local é máis silencioso.",
bunker: "Deixa Amber para asinaturas importantes, ou cambia a un bunker cando esperes unha conversa longa.", bunker: "Deixa Amber para asinaturas importantes, ou cambia a un bunker cando esperes unha conversa longa.",
}, },
globalSearchUi: {
open: "Buscar",
placeholder: "Busca spots, crews, eventos, material…",
hint: "⌘K / Ctrl+K",
recent: "Recentes",
pages: "Páxinas",
spots: "Spots",
shops: "Tendas",
crews: "Crews",
events: "Eventos",
gear: "Material",
skaters: "Skaters",
signers: "Sinadores",
trust: "Confianza",
noResults: "Sen resultados para «{{query}}».",
},
}; };
export default batch1; export default batch1;

View file

@ -1356,6 +1356,22 @@ const batch1 = {
body: "Amber हर हस्ताक्षरित कार्रवाई पर स्वीकृति माँगता है। पढ़ना और बैकग्राउंड डिक्रिप्शन स्थानीय रहते हैं और कोई प्रॉम्प्ट नहीं दिखाते। ग्रुप DM भेजते समय हर प्राप्तकर्ता के लिए एक बार पूछा जाता है (एन्क्रिप्ट + साइन), और Cordn कोऑर्डिनेटर के हर अनुरोध पर पूछ सकता है। बार-बार निजी या क्रू चैट के लिए, bunker या स्थानीय कुंजी अधिक शांत रहती है।", body: "Amber हर हस्ताक्षरित कार्रवाई पर स्वीकृति माँगता है। पढ़ना और बैकग्राउंड डिक्रिप्शन स्थानीय रहते हैं और कोई प्रॉम्प्ट नहीं दिखाते। ग्रुप DM भेजते समय हर प्राप्तकर्ता के लिए एक बार पूछा जाता है (एन्क्रिप्ट + साइन), और Cordn कोऑर्डिनेटर के हर अनुरोध पर पूछ सकता है। बार-बार निजी या क्रू चैट के लिए, bunker या स्थानीय कुंजी अधिक शांत रहती है।",
bunker: "उच्च-मूल्य वाले साइनिंग के लिए Amber रखें, या जब लंबी बातचीत की उम्मीद हो तो bunker पर स्विच करें।", bunker: "उच्च-मूल्य वाले साइनिंग के लिए Amber रखें, या जब लंबी बातचीत की उम्मीद हो तो bunker पर स्विच करें।",
}, },
globalSearchUi: {
open: "खोजें",
placeholder: "स्पॉट, क्रू, इवेंट, गियर खोजें…",
hint: "⌘K / Ctrl+K",
recent: "हाल के",
pages: "पेज",
spots: "स्पॉट",
shops: "दुकानें",
crews: "क्रू",
events: "इवेंट",
gear: "गियर",
skaters: "स्केटर",
signers: "साइनर",
trust: "भरोसा",
noResults: "“{{query}}” के लिए कोई परिणाम नहीं।",
},
}; };
export default batch1; export default batch1;

View file

@ -427,6 +427,22 @@ const driftStrings = {
body: "Amber meminta persetujuan untuk setiap tindakan yang ditandatangani. Pembacaan dan dekripsi latar belakang tetap lokal dan tidak memunculkan permintaan. Mengirim DM grup meminta sekali per penerima (enkripsi + tanda tangan), dan Cordn dapat meminta per permintaan koordinator. Untuk chat pribadi atau kru yang sering, bunker atau kunci lokal lebih tenang.", body: "Amber meminta persetujuan untuk setiap tindakan yang ditandatangani. Pembacaan dan dekripsi latar belakang tetap lokal dan tidak memunculkan permintaan. Mengirim DM grup meminta sekali per penerima (enkripsi + tanda tangan), dan Cordn dapat meminta per permintaan koordinator. Untuk chat pribadi atau kru yang sering, bunker atau kunci lokal lebih tenang.",
bunker: "Pertahankan Amber untuk penandatanganan bernilai tinggi, atau beralih ke bunker saat kamu memperkirakan obrolan panjang.", bunker: "Pertahankan Amber untuk penandatanganan bernilai tinggi, atau beralih ke bunker saat kamu memperkirakan obrolan panjang.",
}, },
globalSearchUi: {
open: "Cari",
placeholder: "Cari spot, kru, acara, gear…",
hint: "⌘K / Ctrl+K",
recent: "Terbaru",
pages: "Halaman",
spots: "Spot",
shops: "Toko",
crews: "Kru",
events: "Acara",
gear: "Gear",
skaters: "Skater",
signers: "Penandatangan",
trust: "Kepercayaan",
noResults: "Tidak ada hasil untuk “{{query}}”.",
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -433,6 +433,22 @@ const drift = {
body: "Amber chiede conferma per ogni azione firmata. Le letture e le decifrature in background restano locali e non generano richieste. L'invio di un DM di gruppo chiede una volta per destinatario (cifratura + firma) e Cordn può chiedere per ogni richiesta del coordinatore. Per chat private o di crew frequenti, un bunker o una chiave locale è più silenzioso.", body: "Amber chiede conferma per ogni azione firmata. Le letture e le decifrature in background restano locali e non generano richieste. L'invio di un DM di gruppo chiede una volta per destinatario (cifratura + firma) e Cordn può chiedere per ogni richiesta del coordinatore. Per chat private o di crew frequenti, un bunker o una chiave locale è più silenzioso.",
bunker: "Tieni Amber per le firme di alto valore, oppure passa a un bunker quando prevedi una lunga conversazione.", bunker: "Tieni Amber per le firme di alto valore, oppure passa a un bunker quando prevedi una lunga conversazione.",
}, },
globalSearchUi: {
open: "Cerca",
placeholder: "Cerca spot, crew, eventi, attrezzatura…",
hint: "⌘K / Ctrl+K",
recent: "Recenti",
pages: "Pagine",
spots: "Spot",
shops: "Skate shop",
crews: "Crew",
events: "Eventi",
gear: "Attrezzatura",
skaters: "Skaters",
signers: "Signer",
trust: "Fiducia",
noResults: "Nessun risultato per «{{query}}».",
},
}; };
export default drift; export default drift;

View file

@ -443,6 +443,22 @@ const drift: Dict = {
body: "Amber は署名付きの操作ごとに承認を求めます。読み取りとバックグラウンドの復号はローカルで行われ、プロンプトは表示されません。グループ DM の送信は受信者ごとに 1 回(暗号化 + 署名)確認し、Cordn はコーディネーターのリクエストごとに確認することがあります。頻繁なプライベートチャットやクルーチャットなら、バンカーかローカルキーの方が静かです。", body: "Amber は署名付きの操作ごとに承認を求めます。読み取りとバックグラウンドの復号はローカルで行われ、プロンプトは表示されません。グループ DM の送信は受信者ごとに 1 回(暗号化 + 署名)確認し、Cordn はコーディネーターのリクエストごとに確認することがあります。頻繁なプライベートチャットやクルーチャットなら、バンカーかローカルキーの方が静かです。",
bunker: "高価値な署名には Amber を使い、長いチャットが予想されるときはバンカーに切り替えてください。", bunker: "高価値な署名には Amber を使い、長いチャットが予想されるときはバンカーに切り替えてください。",
}, },
globalSearchUi: {
open: "検索",
placeholder: "スポット、クルー、イベント、ギアを検索…",
hint: "⌘K / Ctrl+K",
recent: "最近",
pages: "ページ",
spots: "スポット",
shops: "ショップ",
crews: "クルー",
events: "イベント",
gear: "ギア",
skaters: "スケーター",
signers: "署名器",
trust: "信頼",
noResults: "「{{query}}」の結果はありません。",
},
}; };
export default drift; export default drift;

View file

@ -443,6 +443,22 @@ const drift: Dict = {
body: "Amber는 서명 작업마다 승인을 요청합니다. 읽기와 백그라운드 복호화는 로컬에서 처리되어 프롬프트가 뜨지 않습니다. 그룹 DM을 보낼 때는 수신자마다 한 번(암호화 + 서명) 요청하고, Cordn은 코디네이터 요청마다 물어볼 수 있습니다. 자주 하는 비공개 채팅이나 크루 채팅에는 벙커나 로컬 키가 더 조용합니다.", body: "Amber는 서명 작업마다 승인을 요청합니다. 읽기와 백그라운드 복호화는 로컬에서 처리되어 프롬프트가 뜨지 않습니다. 그룹 DM을 보낼 때는 수신자마다 한 번(암호화 + 서명) 요청하고, Cordn은 코디네이터 요청마다 물어볼 수 있습니다. 자주 하는 비공개 채팅이나 크루 채팅에는 벙커나 로컬 키가 더 조용합니다.",
bunker: "고가치 서명에는 Amber를 유지하고, 긴 대화가 예상되면 벙커로 전환하세요.", bunker: "고가치 서명에는 Amber를 유지하고, 긴 대화가 예상되면 벙커로 전환하세요.",
}, },
globalSearchUi: {
open: "검색",
placeholder: "스팟, 크루, 이벤트, 기어 검색…",
hint: "⌘K / Ctrl+K",
recent: "최근",
pages: "페이지",
spots: "스팟",
shops: "숍",
crews: "크루",
events: "이벤트",
gear: "기어",
skaters: "스케이터",
signers: "서명기",
trust: "신뢰",
noResults: "“{{query}}”에 대한 결과가 없습니다.",
},
}; };
export default drift; export default drift;

View file

@ -0,0 +1,35 @@
const globalSearchUi = {
en: {
open: 'Search',
placeholder: 'Search spots, crews, events, gear…',
hint: '⌘K / Ctrl+K',
recent: 'Recent',
pages: 'Pages',
spots: 'Spots',
shops: 'Shops',
crews: 'Crews',
events: 'Events',
gear: 'Gear',
skaters: 'Skaters',
signers: 'Signers',
trust: 'Trust',
noResults: 'No results for “{{query}}”.',
},
es: {
open: 'Buscar',
placeholder: 'Busca spots, peñas, eventos, material…',
hint: '⌘K / Ctrl+K',
recent: 'Reciente',
pages: 'Secciones',
spots: 'Spots',
shops: 'Tiendas',
crews: 'Peñas',
events: 'Eventos',
gear: 'Material',
skaters: 'Skaters',
signers: 'Firmantes',
trust: 'Confianza',
noResults: 'No hay resultados para «{{query}}».',
},
};
export default globalSearchUi;

View file

@ -6,6 +6,7 @@ import notify from './notify';
import misc from './misc'; import misc from './misc';
import cordnUi from './cordnUi'; import cordnUi from './cordnUi';
import communityUi from './communityUi'; import communityUi from './communityUi';
import globalSearchUi from './globalSearchUi';
import signerPromptUi from './signerPromptUi'; import signerPromptUi from './signerPromptUi';
import newsletterDraftUi from './newsletterDraftUi'; import newsletterDraftUi from './newsletterDraftUi';
@ -23,6 +24,7 @@ export const legacyEn = {
legacyMiscUi: misc.en, legacyMiscUi: misc.en,
cordnUi: cordnUi.en, cordnUi: cordnUi.en,
communityUi: communityUi.en, communityUi: communityUi.en,
globalSearchUi: globalSearchUi.en,
signerPromptUi: signerPromptUi.en, signerPromptUi: signerPromptUi.en,
newsletterDraftUi: newsletterDraftUi.en, newsletterDraftUi: newsletterDraftUi.en,
}; };
@ -36,6 +38,7 @@ export const legacyEs = {
legacyMiscUi: misc.es, legacyMiscUi: misc.es,
cordnUi: cordnUi.es, cordnUi: cordnUi.es,
communityUi: communityUi.es, communityUi: communityUi.es,
globalSearchUi: globalSearchUi.es,
signerPromptUi: signerPromptUi.es, signerPromptUi: signerPromptUi.es,
newsletterDraftUi: newsletterDraftUi.es, newsletterDraftUi: newsletterDraftUi.es,
}; };

View file

@ -431,6 +431,22 @@ const drift = {
body: "Amber vraagt goedkeuring bij elke ondertekende actie. Lezen en op de achtergrond ontsleutelen blijven lokaal en geven geen prompt. Bij het versturen van een groeps-DM wordt één keer per ontvanger gevraagd (versleutelen + ondertekenen), en Cordn kan per coördinatorverzoek vragen. Voor veelvuldige privé- of crewchats is een bunker of lokale sleutel rustiger.", body: "Amber vraagt goedkeuring bij elke ondertekende actie. Lezen en op de achtergrond ontsleutelen blijven lokaal en geven geen prompt. Bij het versturen van een groeps-DM wordt één keer per ontvanger gevraagd (versleutelen + ondertekenen), en Cordn kan per coördinatorverzoek vragen. Voor veelvuldige privé- of crewchats is een bunker of lokale sleutel rustiger.",
bunker: "Bewaar Amber voor ondertekening van hoge waarde, of stap over op een bunker als je een lang gesprek verwacht.", bunker: "Bewaar Amber voor ondertekening van hoge waarde, of stap over op een bunker als je een lang gesprek verwacht.",
}, },
globalSearchUi: {
open: "Zoeken",
placeholder: "Zoek spots, crews, events, gear…",
hint: "⌘K / Ctrl+K",
recent: "Recent",
pages: "Pagina's",
spots: "Spots",
shops: "Shops",
crews: "Crews",
events: "Events",
gear: "Gear",
skaters: "Skaters",
signers: "Signers",
trust: "Vertrouwen",
noResults: "Geen resultaten voor ‘{{query}}’.",
},
}; };
export default drift; export default drift;

View file

@ -431,6 +431,22 @@ const drift = {
body: "Amber prosi o zatwierdzenie każdej podpisanej akcji. Odczyty i odszyfrowywanie w tle pozostają lokalne i nie wywołują monitów. Wysłanie DM grupowego prosi raz na odbiorcę (szyfrowanie + podpis), a Cordn może pytać przy każdym żądaniu koordynatora. Przy częstym czacie prywatnym lub ekipy cichszy jest bunkier lub klucz lokalny.", body: "Amber prosi o zatwierdzenie każdej podpisanej akcji. Odczyty i odszyfrowywanie w tle pozostają lokalne i nie wywołują monitów. Wysłanie DM grupowego prosi raz na odbiorcę (szyfrowanie + podpis), a Cordn może pytać przy każdym żądaniu koordynatora. Przy częstym czacie prywatnym lub ekipy cichszy jest bunkier lub klucz lokalny.",
bunker: "Zostaw Amber do podpisywania ważnych rzeczy albo przełącz się na bunkier, gdy spodziewasz się długiej rozmowy.", bunker: "Zostaw Amber do podpisywania ważnych rzeczy albo przełącz się na bunkier, gdy spodziewasz się długiej rozmowy.",
}, },
globalSearchUi: {
open: "Szukaj",
placeholder: "Szukaj spotów, ekip, wydarzeń, sprzętu…",
hint: "⌘K / Ctrl+K",
recent: "Ostatnie",
pages: "Strony",
spots: "Spoty",
shops: "Sklepy",
crews: "Crewy",
events: "Wydarzenia",
gear: "Sprzęt",
skaters: "Skaterzy",
signers: "Signery",
trust: "Zaufanie",
noResults: "Brak wyników dla „{{query}}”.",
},
}; };
export default drift; export default drift;

View file

@ -433,6 +433,22 @@ const drift = {
body: "O Amber pede aprovação em cada ação assinada. Leituras e descriptografias em segundo plano ficam locais e não geram avisos. Enviar uma DM em grupo pede uma vez por destinatário (criptografar + assinar), e o Cordn pode pedir a cada solicitação do coordenador. Para chat privado ou de crew frequente, um bunker ou uma chave local é mais silencioso.", body: "O Amber pede aprovação em cada ação assinada. Leituras e descriptografias em segundo plano ficam locais e não geram avisos. Enviar uma DM em grupo pede uma vez por destinatário (criptografar + assinar), e o Cordn pode pedir a cada solicitação do coordenador. Para chat privado ou de crew frequente, um bunker ou uma chave local é mais silencioso.",
bunker: "Mantenha o Amber para assinaturas importantes, ou mude para um bunker quando esperar uma conversa longa.", bunker: "Mantenha o Amber para assinaturas importantes, ou mude para um bunker quando esperar uma conversa longa.",
}, },
globalSearchUi: {
open: "Buscar",
placeholder: "Buscar spots, crews, eventos, equipamento…",
hint: "⌘K / Ctrl+K",
recent: "Recentes",
pages: "Páginas",
spots: "Spots",
shops: "Lojas",
crews: "Crews",
events: "Eventos",
gear: "Equipamento",
skaters: "Skatistas",
signers: "Assinadores",
trust: "Confiança",
noResults: "Nenhum resultado para “{{query}}”.",
},
}; };
export default drift; export default drift;

View file

@ -431,6 +431,22 @@ const drift = {
body: "Amber cere aprobare pentru fiecare acțiune semnată. Citirile și decriptările din fundal rămân locale și nu afișează solicitări. Trimiterea unui DM de grup cere o dată per destinatar (criptare + semnare), iar Cordn poate cere la fiecare solicitare a coordonatorului. Pentru chat privat sau de echipă frecvent, un bunker sau o cheie locală este mai silențios.", body: "Amber cere aprobare pentru fiecare acțiune semnată. Citirile și decriptările din fundal rămân locale și nu afișează solicitări. Trimiterea unui DM de grup cere o dată per destinatar (criptare + semnare), iar Cordn poate cere la fiecare solicitare a coordonatorului. Pentru chat privat sau de echipă frecvent, un bunker sau o cheie locală este mai silențios.",
bunker: "Păstrează Amber pentru semnături importante sau treci la un bunker când te aștepți la o conversație lungă.", bunker: "Păstrează Amber pentru semnături importante sau treci la un bunker când te aștepți la o conversație lungă.",
}, },
globalSearchUi: {
open: "Caută",
placeholder: "Caută spoturi, crews, evenimente, echipament…",
hint: "⌘K / Ctrl+K",
recent: "Recente",
pages: "Pagini",
spots: "Spoturi",
shops: "Magazine",
crews: "Crews",
events: "Evenimente",
gear: "Echipament",
skaters: "Skateri",
signers: "Semnatari",
trust: "Încredere",
noResults: "Niciun rezultat pentru „{{query}}”.",
},
}; };
export default drift; export default drift;

View file

@ -435,6 +435,22 @@ const drift = {
body: "Amber запрашивает подтверждение для каждого подписываемого действия. Чтение и фоновое расшифрование выполняются локально и не вызывают запросов. Отправка группового DM требует по одному запросу на получателя (шифрование + подпись), а Cordn может запрашивать подтверждение на каждый запрос координатора. Для частного или экипажного чата тише использовать бункер или локальный ключ.", body: "Amber запрашивает подтверждение для каждого подписываемого действия. Чтение и фоновое расшифрование выполняются локально и не вызывают запросов. Отправка группового DM требует по одному запросу на получателя (шифрование + подпись), а Cordn может запрашивать подтверждение на каждый запрос координатора. Для частного или экипажного чата тише использовать бункер или локальный ключ.",
bunker: "Оставьте Amber для важных подписей или переключитесь на бункер, если ожидаете долгий разговор.", bunker: "Оставьте Amber для важных подписей или переключитесь на бункер, если ожидаете долгий разговор.",
}, },
globalSearchUi: {
open: "Поиск",
placeholder: "Поиск спотов, экипажей, событий, снаряжения…",
hint: "⌘K / Ctrl+K",
recent: "Недавние",
pages: "Страницы",
spots: "Споты",
shops: "Магазины",
crews: "Экипажи",
events: "События",
gear: "Снаряжение",
skaters: "Скейтеры",
signers: "Подписанты",
trust: "Доверие",
noResults: "Нет результатов по запросу «{{query}}».",
},
}; };
export default drift; export default drift;

View file

@ -435,6 +435,22 @@ const drift = {
body: "Amber begär godkännande för varje signerad åtgärd. Läsningar och bakgrundsdekrypteringar sker lokalt och ger inga frågor. När du skickar ett grupp-DM frågas det en gång per mottagare (kryptera + signera), och Cordn kan fråga vid varje koordinatorbegäran. För frekvent privat- eller crewchatt är en bunker eller lokal nyckel tystare.", body: "Amber begär godkännande för varje signerad åtgärd. Läsningar och bakgrundsdekrypteringar sker lokalt och ger inga frågor. När du skickar ett grupp-DM frågas det en gång per mottagare (kryptera + signera), och Cordn kan fråga vid varje koordinatorbegäran. För frekvent privat- eller crewchatt är en bunker eller lokal nyckel tystare.",
bunker: "Behåll Amber för signering av högt värde, eller byt till en bunker när du väntar en lång konversation.", bunker: "Behåll Amber för signering av högt värde, eller byt till en bunker när du väntar en lång konversation.",
}, },
globalSearchUi: {
open: "Sök",
placeholder: "Sök spots, crews, event, utrustning…",
hint: "⌘K / Ctrl+K",
recent: "Senaste",
pages: "Sidor",
spots: "Spots",
shops: "Butiker",
crews: "Crews",
events: "Event",
gear: "Utrustning",
skaters: "Skatare",
signers: "Signerare",
trust: "Förtroende",
noResults: "Inga resultat för ”{{query}}”.",
},
}; };
export default drift; export default drift;

View file

@ -427,6 +427,22 @@ const driftStrings = {
body: "Amber จะขออนุมัติทุกครั้งที่มีการลงนาม การอ่านและการถอดรหัสเบื้องหลังทำในเครื่องและไม่มีการแจ้ง การส่ง DM กลุ่มจะถามครั้งเดียวต่อผู้รับหนึ่งคน (เข้ารหัส + ลงนาม) และ Cordn อาจถามในแต่ละคำขอของผู้ประสานงาน หากแชตส่วนตัวหรือแชตทีมบ่อย ๆ การใช้ bunker หรือคีย์ในเครื่องจะเงียบกว่า", body: "Amber จะขออนุมัติทุกครั้งที่มีการลงนาม การอ่านและการถอดรหัสเบื้องหลังทำในเครื่องและไม่มีการแจ้ง การส่ง DM กลุ่มจะถามครั้งเดียวต่อผู้รับหนึ่งคน (เข้ารหัส + ลงนาม) และ Cordn อาจถามในแต่ละคำขอของผู้ประสานงาน หากแชตส่วนตัวหรือแชตทีมบ่อย ๆ การใช้ bunker หรือคีย์ในเครื่องจะเงียบกว่า",
bunker: "เก็บ Amber ไว้สำหรับการลงนามที่สำคัญ หรือเปลี่ยนไปใช้ bunker เมื่อคาดว่าจะคุยกันยาว", bunker: "เก็บ Amber ไว้สำหรับการลงนามที่สำคัญ หรือเปลี่ยนไปใช้ bunker เมื่อคาดว่าจะคุยกันยาว",
}, },
globalSearchUi: {
open: "ค้นหา",
placeholder: "ค้นหาสปอต, ครีว, อีเวนต์, เกียร์…",
hint: "⌘K / Ctrl+K",
recent: "ล่าสุด",
pages: "หน้า",
spots: "สปอต",
shops: "ร้านค้า",
crews: "Crews",
events: "อีเวนต์",
gear: "เกียร์",
skaters: "นักสเก็ต",
signers: "ผู้ลงนาม",
trust: "ความน่าเชื่อถือ",
noResults: "ไม่พบผลลัพธ์สำหรับ “{{query}}”",
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -427,6 +427,22 @@ const driftStrings = {
body: "Amber, imzalanan her işlem için onay ister. Okumalar ve arka planda şifre çözme yerelde kalır ve istem göstermez. Grup DM göndermek alıcı başına bir kez sorar (şifreleme + imzalama) ve Cordn her koordinatör isteğinde sorabilir. Sık kullanılan özel veya ekip sohbetleri için bir bunker ya da yerel anahtar daha sessizdir.", body: "Amber, imzalanan her işlem için onay ister. Okumalar ve arka planda şifre çözme yerelde kalır ve istem göstermez. Grup DM göndermek alıcı başına bir kez sorar (şifreleme + imzalama) ve Cordn her koordinatör isteğinde sorabilir. Sık kullanılan özel veya ekip sohbetleri için bir bunker ya da yerel anahtar daha sessizdir.",
bunker: "Yüksek değerli imzalama için Amber'ı kullanmaya devam et veya uzun bir sohbet bekliyorsan bunker'a geç.", bunker: "Yüksek değerli imzalama için Amber'ı kullanmaya devam et veya uzun bir sohbet bekliyorsan bunker'a geç.",
}, },
globalSearchUi: {
open: "Ara",
placeholder: "Spot, crew, etkinlik, ekipman ara…",
hint: "⌘K / Ctrl+K",
recent: "Son",
pages: "Sayfalar",
spots: "Spotlar",
shops: "Dükkanlar",
crews: "Crew'ler",
events: "Etkinlikler",
gear: "Ekipman",
skaters: "Kaykaycılar",
signers: "İmzalayıcılar",
trust: "Güven",
noResults: "“{{query}}” için sonuç yok.",
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -435,6 +435,22 @@ const drift = {
body: "Amber запитує підтвердження для кожної підписаної дії. Читання та фонове розшифрування відбуваються локально й не викликають запитів. Надсилання групового DM запитує по одному разу на кожного отримувача (шифрування + підпис), а Cordn може запитувати на кожен запит координатора. Для частого приватного чату або чату екіпажу тихіше використовувати бункер чи локальний ключ.", body: "Amber запитує підтвердження для кожної підписаної дії. Читання та фонове розшифрування відбуваються локально й не викликають запитів. Надсилання групового DM запитує по одному разу на кожного отримувача (шифрування + підпис), а Cordn може запитувати на кожен запит координатора. Для частого приватного чату або чату екіпажу тихіше використовувати бункер чи локальний ключ.",
bunker: "Залиште Amber для важливих підписів або перейдіть на бункер, якщо очікуєте довгу розмову.", bunker: "Залиште Amber для важливих підписів або перейдіть на бункер, якщо очікуєте довгу розмову.",
}, },
globalSearchUi: {
open: "Пошук",
placeholder: "Пошук спотів, екіпажів, подій, спорядження…",
hint: "⌘K / Ctrl+K",
recent: "Нещодавні",
pages: "Сторінки",
spots: "Споти",
shops: "Магазини",
crews: "Екіпажі",
events: "Події",
gear: "Спорядження",
skaters: "Скейтери",
signers: "Підписувачі",
trust: "Довіра",
noResults: "Немає результатів для «{{query}}».",
},
}; };
export default drift; export default drift;

View file

@ -427,6 +427,22 @@ const driftStrings = {
body: "Amber yêu cầu phê duyệt cho mỗi hành động được ký. Việc đọc và giải mã nền diễn ra cục bộ và không hiện nhắc. Gửi DM nhóm sẽ hỏi một lần cho mỗi người nhận (mã hóa + ký), và Cordn có thể hỏi với mỗi yêu cầu của điều phối viên. Với chat riêng hoặc chat đội thường xuyên, bunker hoặc khóa cục bộ sẽ yên tĩnh hơn.", body: "Amber yêu cầu phê duyệt cho mỗi hành động được ký. Việc đọc và giải mã nền diễn ra cục bộ và không hiện nhắc. Gửi DM nhóm sẽ hỏi một lần cho mỗi người nhận (mã hóa + ký), và Cordn có thể hỏi với mỗi yêu cầu của điều phối viên. Với chat riêng hoặc chat đội thường xuyên, bunker hoặc khóa cục bộ sẽ yên tĩnh hơn.",
bunker: "Giữ Amber cho việc ký quan trọng, hoặc chuyển sang bunker khi bạn dự đoán một cuộc trò chuyện dài.", bunker: "Giữ Amber cho việc ký quan trọng, hoặc chuyển sang bunker khi bạn dự đoán một cuộc trò chuyện dài.",
}, },
globalSearchUi: {
open: "Tìm kiếm",
placeholder: "Tìm spot, crew, sự kiện, đồ nghề…",
hint: "⌘K / Ctrl+K",
recent: "Gần đây",
pages: "Trang",
spots: "Spot",
shops: "Cửa hàng",
crews: "Crews",
events: "Sự kiện",
gear: "Đồ nghề",
skaters: "Người trượt ván",
signers: "Signer",
trust: "Tin cậy",
noResults: "Không có kết quả cho “{{query}}”.",
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -443,6 +443,22 @@ const drift: Dict = {
body: "Amber 会在每次签名操作时请求批准。读取和后台解密都在本地进行,不会弹出提示。发送群组私信时每位收件人会请求一次(加密 + 签名),Cordn 可能针对每个协调者请求询问。对于频繁的私聊或车队聊天,用 bunker 或本地密钥会更安静。", body: "Amber 会在每次签名操作时请求批准。读取和后台解密都在本地进行,不会弹出提示。发送群组私信时每位收件人会请求一次(加密 + 签名),Cordn 可能针对每个协调者请求询问。对于频繁的私聊或车队聊天,用 bunker 或本地密钥会更安静。",
bunker: "把 Amber 留给高价值签名,或者在预计会长聊时改用 bunker。", bunker: "把 Amber 留给高价值签名,或者在预计会长聊时改用 bunker。",
}, },
globalSearchUi: {
open: "搜索",
placeholder: "搜索 Spot、Crew、活动、装备…",
hint: "⌘K / Ctrl+K",
recent: "最近",
pages: "页面",
spots: "Spot",
shops: "商店",
crews: "Crews",
events: "活动",
gear: "装备",
skaters: "滑手",
signers: "签名器",
trust: "信任",
noResults: "未找到“{{query}}”的结果。",
},
}; };
export default drift; export default drift;

View file

@ -443,6 +443,22 @@ const drift: Dict = {
body: "Amber 會在每次簽署操作時要求核准。讀取與背景解密都在本機進行,不會跳出提示。傳送群組私訊時,每位收件者會要求一次(加密 + 簽章),而 Cordn 可能針對每個協調者要求詢問。若經常私聊或團隊聊天,使用 bunker 或本機金鑰會更安靜。", body: "Amber 會在每次簽署操作時要求核准。讀取與背景解密都在本機進行,不會跳出提示。傳送群組私訊時,每位收件者會要求一次(加密 + 簽章),而 Cordn 可能針對每個協調者要求詢問。若經常私聊或團隊聊天,使用 bunker 或本機金鑰會更安靜。",
bunker: "把 Amber 留給高價值簽章,或在預期會長時間聊天時改用 bunker。", bunker: "把 Amber 留給高價值簽章,或在預期會長時間聊天時改用 bunker。",
}, },
globalSearchUi: {
open: "搜尋",
placeholder: "搜尋 Spot、Crew、活動、裝備…",
hint: "⌘K / Ctrl+K",
recent: "最近",
pages: "頁面",
spots: "Spot",
shops: "商店",
crews: "Crews",
events: "活動",
gear: "裝備",
skaters: "滑手",
signers: "簽署器",
trust: "信任",
noResults: "找不到「{{query}}」的結果。",
},
}; };
export default drift; export default drift;

5
src/lib/globalSearch.ts Normal file
View file

@ -0,0 +1,5 @@
export const GLOBAL_SEARCH_EVENT = 'skatespots:open-global-search';
export function openGlobalSearch(): void {
window.dispatchEvent(new Event(GLOBAL_SEARCH_EVENT));
}

View file

@ -555,8 +555,8 @@ export default function MapPage() {
const [showGear, setShowGear] = useState(true); const [showGear, setShowGear] = useState(true);
const [showTours, setShowTours] = useState(!focusGear); const [showTours, setShowTours] = useState(!focusGear);
// Opt-in layers — no relay query until the user turns them on. // Opt-in layers — no relay query until the user turns them on.
const [showEvents, setShowEvents] = useState(false); const [showEvents, setShowEvents] = useLocalStorage('skatespots:map-show-events', false);
const [showCrews, setShowCrews] = useState(false); const [showCrews, setShowCrews] = useLocalStorage('skatespots:map-show-crews', false);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
// The user's own profile picture, shown on the "you are here" pin. // The user's own profile picture, shown on the "you are here" pin.
const meAuthor = useAuthor(user?.pubkey || ''); const meAuthor = useAuthor(user?.pubkey || '');