refactor: split non-component exports (react-refresh) into their own modules
Removes the last react-refresh disables by splitting exports instead: - contexts/language.ts: Language/Translations types + LanguageContext + useTranslation/useT (LanguageProvider stays a component-only file); 12 importers repointed. - lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown) - lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter) - EventCountdown: deleted the unused useEventStatus hook - NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim - AGENTS.md updated (lint stays clean with no blanket disables) Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
parent
584561e390
commit
b2a6108eeb
22 changed files with 186 additions and 196 deletions
|
|
@ -95,9 +95,10 @@ session with **no other memory of this project**. So:
|
||||||
- **Baselines** (verified 2026-09-26): `npm test` → **0 errors, 0 warnings**,
|
- **Baselines** (verified 2026-09-26): `npm test` → **0 errors, 0 warnings**,
|
||||||
**101 tests**, build ok. `package.json` version **1.4.5**.
|
**101 tests**, build ok. `package.json` version **1.4.5**.
|
||||||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||||||
· `no-unused-vars` = 0 · `react-refresh` = 0 (the last were intentional
|
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||||
component+hook/helper colocations carrying a justified file-level disable).
|
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||||||
Keep it at zero — don't reintroduce any.
|
`lib/trustFilter.ts`) and unused hooks/functions deleted — no blanket
|
||||||
|
disables. Keep it at zero — don't reintroduce any.
|
||||||
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
|
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
|
||||||
names and typed shorthand destructuring make it unsafe. Fix position-precise.
|
names and typed shorthand destructuring make it unsafe. Fix position-precise.
|
||||||
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AlertTriangle, X, Download, CheckCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
type NavTab,
|
type NavTab,
|
||||||
} from '@/hooks/useNavPreferences';
|
} from '@/hooks/useNavPreferences';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
import { useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ArrowUp, ArrowDown, Eye, EyeOff, Lock, RotateCcw, Monitor } from 'lucide-react';
|
import { ArrowUp, ArrowDown, Eye, EyeOff, Lock, RotateCcw, Monitor } from 'lucide-react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- EventCountdown component + useEventStatus hook are colocated by design (dev-only HMR rule). */
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
@ -87,15 +86,3 @@ export function EventCountdown({
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standalone helper to get just the status (for conditional styling). */
|
|
||||||
export function useEventStatus(start: number, end?: number): EventStatus {
|
|
||||||
const [tick, forceTick] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
// `tick` forces recompute on the interval (it is not used inside the memo).
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useTranslation, type Language } from '@/contexts/LanguageContext';
|
import { useTranslation, type Language } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface LanguageOption {
|
interface LanguageOption {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
|
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
|
||||||
import { LanguageToggle } from '@/components/LanguageToggle';
|
import { LanguageToggle } from '@/components/LanguageToggle';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- SortDropdown component + its sort option constants/helpers are intentionally colocated (dev-only HMR rule). */
|
|
||||||
import { ArrowUpDown, Clock, TrendingUp, MapPin, Zap, Shield } from 'lucide-react';
|
import { ArrowUpDown, Clock, TrendingUp, MapPin, Zap, Shield } from 'lucide-react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -100,43 +99,3 @@ export function SortDropdown({
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to calculate popularity score for sorting
|
|
||||||
* Based on comments, reactions, and other engagement metrics
|
|
||||||
*/
|
|
||||||
export function calculatePopularity(
|
|
||||||
commentCount: number = 0,
|
|
||||||
reactionCount: number = 0,
|
|
||||||
zapCount: number = 0
|
|
||||||
): number {
|
|
||||||
// Weighted popularity: comments worth 3x, reactions 1x, zaps 5x
|
|
||||||
return (commentCount * 3) + (reactionCount * 1) + (zapCount * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate distance between two coordinates in kilometers
|
|
||||||
* Uses Haversine formula
|
|
||||||
*/
|
|
||||||
export function calculateDistance(
|
|
||||||
lat1: number,
|
|
||||||
lon1: number,
|
|
||||||
lat2: number,
|
|
||||||
lon2: number
|
|
||||||
): number {
|
|
||||||
const R = 6371; // Earth's radius in km
|
|
||||||
const dLat = toRad(lat2 - lat1);
|
|
||||||
const dLon = toRad(lon2 - lon1);
|
|
||||||
|
|
||||||
const a =
|
|
||||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
||||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
|
||||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
||||||
|
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
return R * c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRad(degrees: number): number {
|
|
||||||
return degrees * (Math.PI / 180);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { TrustFilter } from '@/components/TrustFilter';
|
||||||
import {
|
import {
|
||||||
TrustFilter,
|
|
||||||
filterByTrustLevel,
|
filterByTrustLevel,
|
||||||
type TrustFilterLevel,
|
type TrustFilterLevel,
|
||||||
} from '@/components/TrustFilter';
|
} from '@/lib/trustFilter';
|
||||||
import {
|
import {
|
||||||
useSpotChat,
|
useSpotChat,
|
||||||
useSendSpotChat,
|
useSendSpotChat,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- TrustFilter component + its level constants/helpers are intentionally colocated (dev-only HMR rule). */
|
|
||||||
import { Shield, CheckCircle2, Users } from 'lucide-react';
|
import { Shield, CheckCircle2, Users } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -12,8 +12,6 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type TrustFilterLevel = 'all' | 'verified' | 'trusted' | 'following';
|
|
||||||
|
|
||||||
interface TrustFilterProps {
|
interface TrustFilterProps {
|
||||||
value: TrustFilterLevel;
|
value: TrustFilterLevel;
|
||||||
onChange: (value: TrustFilterLevel) => void;
|
onChange: (value: TrustFilterLevel) => void;
|
||||||
|
|
@ -137,26 +135,3 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
|
||||||
/**
|
/**
|
||||||
* Helper function to filter items by trust score
|
* Helper function to filter items by trust score
|
||||||
*/
|
*/
|
||||||
export function filterByTrustLevel<T extends { pubkey: string }>(
|
|
||||||
items: T[],
|
|
||||||
level: TrustFilterLevel,
|
|
||||||
getTrustScore: (pubkey: string) => number | undefined
|
|
||||||
): T[] {
|
|
||||||
if (level === 'all') return items;
|
|
||||||
|
|
||||||
return items.filter((item) => {
|
|
||||||
const score = getTrustScore(item.pubkey);
|
|
||||||
if (score === undefined) return false;
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case 'verified':
|
|
||||||
return score >= 90;
|
|
||||||
case 'trusted':
|
|
||||||
return score >= 60;
|
|
||||||
case 'following':
|
|
||||||
return score === 90 || score === 100;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,21 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- LanguageProvider + useTranslation hook + context are colocated by design (idiomatic context module; dev-only HMR rule). */
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import {
|
||||||
// Supported languages
|
LanguageContext,
|
||||||
export type Language = 'en' | 'es';
|
getNestedValue,
|
||||||
|
interpolate,
|
||||||
// Translation key type - allows nested keys like 'nav.home'
|
translations,
|
||||||
type TranslationKey = string;
|
type Language,
|
||||||
|
} from './language';
|
||||||
// Translation values can be strings or nested objects
|
|
||||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
|
||||||
|
|
||||||
// Translations structure
|
|
||||||
export type Translations = {
|
|
||||||
[key: string]: TranslationValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LanguageContextType {
|
|
||||||
/** Current language */
|
|
||||||
language: Language;
|
|
||||||
/** Set the current language */
|
|
||||||
setLanguage: (lang: Language) => void;
|
|
||||||
/** Translate a key - supports dot notation for nested keys */
|
|
||||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
|
|
||||||
/** Check if current language is RTL (for future Arabic support, etc.) */
|
|
||||||
isRTL: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
// Import translations
|
|
||||||
import en from '@/i18n/en';
|
|
||||||
import es from '@/i18n/es';
|
|
||||||
|
|
||||||
const translations: Record<Language, Translations> = { en, es };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nested value from object using dot notation
|
|
||||||
* e.g., getNestedValue({ nav: { home: 'Home' } }, 'nav.home') => 'Home'
|
|
||||||
*/
|
|
||||||
function getNestedValue(obj: Translations, path: string): string | undefined {
|
|
||||||
const keys = path.split('.');
|
|
||||||
let current: TranslationValue = obj;
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
if (current && typeof current === 'object' && key in current) {
|
|
||||||
current = current[key];
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof current === 'string' ? current : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace parameters in translation string
|
|
||||||
* e.g., interpolate('Hello {{name}}!', { name: 'World' }) => 'Hello World!'
|
|
||||||
*/
|
|
||||||
function interpolate(str: string, params?: Record<string, string | number>): string {
|
|
||||||
if (!params) return str;
|
|
||||||
|
|
||||||
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
||||||
return params[key]?.toString() ?? `{{${key}}}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LanguageProviderProps {
|
interface LanguageProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides language state + translation. The context, types and hooks live in
|
||||||
|
* `./language` so this file exports only the component (React Fast Refresh).
|
||||||
|
*/
|
||||||
export function LanguageProvider({ children }: LanguageProviderProps) {
|
export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||||
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', 'en');
|
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', 'en');
|
||||||
const [language, setLanguageState] = useState<Language>(storedLanguage);
|
const [language, setLanguageState] = useState<Language>(storedLanguage);
|
||||||
|
|
@ -91,7 +37,7 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const t = useCallback((key: TranslationKey, params?: Record<string, string | number>): string => {
|
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
|
||||||
// Try to get translation in current language
|
// Try to get translation in current language
|
||||||
const translation = getNestedValue(translations[language], key);
|
const translation = getNestedValue(translations[language], key);
|
||||||
|
|
||||||
|
|
@ -121,24 +67,3 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||||
</LanguageContext.Provider>
|
</LanguageContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access translation functions and language state
|
|
||||||
*/
|
|
||||||
export function useTranslation() {
|
|
||||||
const context = useContext(LanguageContext);
|
|
||||||
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useTranslation must be used within a LanguageProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get just the translation function (convenience)
|
|
||||||
*/
|
|
||||||
export function useT() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
75
src/contexts/language.ts
Normal file
75
src/contexts/language.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import en from '@/i18n/en';
|
||||||
|
import es from '@/i18n/es';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language context + types + hooks. Split from the `LanguageProvider` component
|
||||||
|
* so the component file exports only a component (React Fast Refresh), per the
|
||||||
|
* `react-refresh/only-export-components` rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Language = 'en' | 'es';
|
||||||
|
|
||||||
|
type TranslationKey = string;
|
||||||
|
type TranslationValue = string | { [key: string]: TranslationValue };
|
||||||
|
|
||||||
|
export type Translations = {
|
||||||
|
[key: string]: TranslationValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LanguageContextType {
|
||||||
|
/** Current language */
|
||||||
|
language: Language;
|
||||||
|
/** Set the current language */
|
||||||
|
setLanguage: (lang: Language) => void;
|
||||||
|
/** Translate a key - supports dot notation for nested keys */
|
||||||
|
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||||
|
/** Check if current language is RTL (for future Arabic support, etc.) */
|
||||||
|
isRTL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const translations: Record<Language, Translations> = { en, es };
|
||||||
|
|
||||||
|
/** Get a nested value from an object using dot notation (`nav.home`). */
|
||||||
|
export function getNestedValue(obj: Translations, path: string): string | undefined {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current: TranslationValue = obj;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === 'string' ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace `{{param}}` placeholders in a translation string. */
|
||||||
|
export function interpolate(str: string, params?: Record<string, string | number>): string {
|
||||||
|
if (!params) return str;
|
||||||
|
|
||||||
|
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||||
|
return params[key]?.toString() ?? `{{${key}}}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to access translation functions and language state. */
|
||||||
|
export function useTranslation() {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTranslation must be used within a LanguageProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to get just the translation function (convenience). */
|
||||||
|
export function useT() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
36
src/lib/sortOptions.ts
Normal file
36
src/lib/sortOptions.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Sort/geo helpers shared across pages. Kept out of SortDropdown.tsx so that
|
||||||
|
* component file exports only a component (React Fast Refresh).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Weighted popularity score: comments 3x, reactions 1x, zaps 5x. */
|
||||||
|
export function calculatePopularity(
|
||||||
|
commentCount = 0,
|
||||||
|
reactionCount = 0,
|
||||||
|
zapCount = 0,
|
||||||
|
): number {
|
||||||
|
return commentCount * 3 + reactionCount * 1 + zapCount * 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Great-circle distance between two coordinates, in kilometres (Haversine). */
|
||||||
|
export function calculateDistance(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number,
|
||||||
|
): number {
|
||||||
|
const R = 6371; // Earth's radius in km
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
34
src/lib/trustFilter.ts
Normal file
34
src/lib/trustFilter.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Trust-filter levels + filtering helper. Kept out of TrustFilter.tsx so that
|
||||||
|
* component file exports only a component (React Fast Refresh).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TrustFilterLevel = 'all' | 'verified' | 'trusted' | 'following';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter items by the web-of-trust score of their author.
|
||||||
|
* `verified` ≥ 90, `trusted` ≥ 60, `following` is the top of the source's graph.
|
||||||
|
*/
|
||||||
|
export function filterByTrustLevel<T extends { pubkey: string }>(
|
||||||
|
items: T[],
|
||||||
|
level: TrustFilterLevel,
|
||||||
|
getTrustScore: (pubkey: string) => number | undefined,
|
||||||
|
): T[] {
|
||||||
|
if (level === 'all') return items;
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
const score = getTrustScore(item.pubkey);
|
||||||
|
if (score === undefined) return false;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'verified':
|
||||||
|
return score >= 90;
|
||||||
|
case 'trusted':
|
||||||
|
return score >= 60;
|
||||||
|
case 'following':
|
||||||
|
return score === 90 || score === 100;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useTours } from '@/hooks/useTours';
|
import { useTours } from '@/hooks/useTours';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
|
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { QualityMeter } from '@/components/QualityMeter';
|
import { QualityMeter } from '@/components/QualityMeter';
|
||||||
import { TagInput } from '@/components/TagInput';
|
import { TagInput } from '@/components/TagInput';
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { EventsCalendar } from '@/components/EventsCalendar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
import { type SortOption } from '@/components/SortDropdown';
|
import { type SortOption } from '@/components/SortDropdown';
|
||||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||||
import { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
import { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
|
|
@ -19,7 +19,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useFollowing } from '@/hooks/useFollowing';
|
import { useFollowing } from '@/hooks/useFollowing';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { useCommentCounts } from '@/hooks/useCommentCounts';
|
import { useCommentCounts } from '@/hooks/useCommentCounts';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import {
|
import {
|
||||||
EVENT_TYPE_LABELS,
|
EVENT_TYPE_LABELS,
|
||||||
TOUR_CATEGORIES,
|
TOUR_CATEGORIES,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
|
||||||
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import { Layout } from '@/components/Layout';
|
||||||
import { LazyMount } from '@/components/LazyMount';
|
import { LazyMount } from '@/components/LazyMount';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
import { type SortOption } from '@/components/SortDropdown';
|
import { type SortOption } from '@/components/SortDropdown';
|
||||||
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import {
|
import {
|
||||||
COACH_SKILL_LABELS,
|
COACH_SKILL_LABELS,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { Layout } from '@/components/Layout';
|
||||||
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
|
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { WeatherToggle } from '@/components/WeatherToggle';
|
import { WeatherToggle } from '@/components/WeatherToggle';
|
||||||
import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
import { TrustFilter } from '@/components/TrustFilter';
|
||||||
import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown';
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
|
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
||||||
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
|
import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -39,7 +41,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO } from '@/lib/types';
|
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO } from '@/lib/types';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- NewsletterPage component + its newsletter data exports are intentionally colocated (dev-only HMR rule). */
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
@ -43,9 +42,6 @@ import { isAdminPubkey } from '@/lib/admins';
|
||||||
// ─── Newsletter issue structure ───────────────────────────────────────────────
|
// ─── Newsletter issue structure ───────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
import { NEWSLETTER_ISSUES, type NewsletterUpdate, type SpotOfTheWeek, type NewsletterIssue } from '@/lib/newsletterIssues';
|
import { NEWSLETTER_ISSUES, type NewsletterUpdate, type SpotOfTheWeek, type NewsletterIssue } from '@/lib/newsletterIssues';
|
||||||
// Preserve the previous public surface for existing importers.
|
|
||||||
export { NEWSLETTER_ISSUES } from '@/lib/newsletterIssues';
|
|
||||||
export type { NewsletterUpdate, SpotOfTheWeek, NewsletterIssue } from '@/lib/newsletterIssues';
|
|
||||||
|
|
||||||
// ─── Nostr post preview + publish ────────────────────────────────────────────
|
// ─── Nostr post preview + publish ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue