Extend admin takedown to Lab profiles and gear/marketplace listings
Completes the admin moderation coverage from the previous commit. Lab profiles (coaches, filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, musicians): - useLabProfiles / useLabProfile and useCoaches / useCoach now filter out admin-hidden entries (list) and 404 hidden ones (detail) via the shared moderation layer. Added filterAdminHiddenMixed() for the mixed-kind Lab union (kind read from each item's event). - CoachDetailPage (the shared Lab detail page) now shows the delete/takedown card to admins too, not just the owner, with admin-specific copy. DeleteEntityDialog already handles the owner-or-admin logic. Gear / marketplace listings (NIP-99, kind 30402): - useMarketplaceItems / useMarketplaceAtSpot filter out admin-hidden listings. - GearSwapItemCard: admins get a "Remove (admin)" action in the card menu with a confirmation dialog that routes through useDeleteEntity (NIP-09 delete + hidden label). Report stays for non-owners. SECURITY.md: updated the admin-moderation coverage list and auditor file map. Build + lint pass, no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
13eece3a1e
commit
82a74e01e0
7 changed files with 161 additions and 23 deletions
15
SECURITY.md
15
SECURITY.md
|
|
@ -216,11 +216,14 @@ intentionally *not* author-filtered.
|
||||||
### Admin moderation / takedowns
|
### Admin moderation / takedowns
|
||||||
|
|
||||||
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
|
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
|
||||||
down any listing (spot, crew, tour, event, shop, …). A takedown publishes both
|
down any listing — spots, crews, tours, events, shops, Lab profiles (coaches,
|
||||||
a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation
|
filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers,
|
||||||
label authored by the admin. The client-side layer (`useAdminModeration`) then
|
musicians), and gear/marketplace listings. A takedown publishes both a NIP-09
|
||||||
hides that content across the app, so takedowns are reliable even when a relay
|
kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation label
|
||||||
won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose
|
authored by the admin. The client-side layer (`useAdminModeration`, wired into
|
||||||
|
every list/detail hook via React Query `select`) then hides that content across
|
||||||
|
the app, so takedowns are reliable even when a relay won't honor a cross-author
|
||||||
|
NIP-09 request. **Trust boundary:** only labels whose
|
||||||
`pubkey` is in the hardcoded admin list are honored — this is re-verified
|
`pubkey` is in the hardcoded admin list are honored — this is re-verified
|
||||||
client-side in `useAdminModeration` and is never derived from a `d` tag or any
|
client-side in `useAdminModeration` and is never derived from a `d` tag or any
|
||||||
attacker-controllable value. Auditors should confirm the admin list is the only
|
attacker-controllable value. Auditors should confirm the admin list is the only
|
||||||
|
|
@ -264,7 +267,7 @@ oversights:
|
||||||
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
||||||
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
||||||
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
|
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
|
||||||
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` |
|
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` |
|
||||||
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
||||||
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
||||||
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
||||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
||||||
|
|
@ -21,6 +24,7 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Flag,
|
Flag,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -28,6 +32,16 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
|
@ -42,13 +56,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const author = useAuthor(item.pubkey);
|
const author = useAuthor(item.pubkey);
|
||||||
|
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
|
||||||
|
const { toast } = useToast();
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
||||||
const avatar = metadata?.picture;
|
const avatar = metadata?.picture;
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
|
|
||||||
const npub = nip19.npubEncode(item.pubkey);
|
const npub = nip19.npubEncode(item.pubkey);
|
||||||
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
||||||
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||||
|
|
||||||
|
const handleAdminRemove = async () => {
|
||||||
|
try {
|
||||||
|
await deleteEntity({
|
||||||
|
event: item.event,
|
||||||
|
reason: `SkateSpots admin removed listing "${item.title}"`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Listing removed',
|
||||||
|
description: 'This listing has been taken down and will be hidden across SkateSpots.',
|
||||||
|
});
|
||||||
|
setShowRemoveDialog(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Remove failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Format price display
|
// Format price display
|
||||||
const priceDisplay = formatPrice(item.price, item.priceType);
|
const priceDisplay = formatPrice(item.price, item.priceType);
|
||||||
|
|
@ -139,8 +177,9 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More options menu */}
|
{/* More options menu — shown to non-owners (Report) and to admins
|
||||||
{!isOwnListingCheck && (
|
(admin takedown). Owners manage their own listing elsewhere. */}
|
||||||
|
{(!isOwnListingCheck || isAdmin) && (
|
||||||
<div className="absolute bottom-2 right-2">
|
<div className="absolute bottom-2 right-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -153,17 +192,31 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
<DropdownMenuItem
|
{!isOwnListingCheck && (
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setShowReportDialog(true);
|
e.stopPropagation();
|
||||||
}}
|
setShowReportDialog(true);
|
||||||
className="text-red-500 focus:text-red-500"
|
}}
|
||||||
>
|
className="text-red-500 focus:text-red-500"
|
||||||
<Flag className="h-4 w-4 mr-2" />
|
>
|
||||||
Report
|
<Flag className="h-4 w-4 mr-2" />
|
||||||
</DropdownMenuItem>
|
Report
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowRemoveDialog(true);
|
||||||
|
}}
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Remove (admin)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -291,6 +344,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
targetEventId={item.id}
|
targetEventId={item.id}
|
||||||
targetTitle={item.title}
|
targetTitle={item.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Admin takedown confirmation */}
|
||||||
|
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||||
|
<AlertDialogContent
|
||||||
|
className="bg-black border-2 border-red-500/50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-500" />
|
||||||
|
Remove this listing? (admin)
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-gray-300">
|
||||||
|
As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the
|
||||||
|
app for everyone and a deletion request will be sent to relays.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleAdminRemove}
|
||||||
|
disabled={isRemoving}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white"
|
||||||
|
>
|
||||||
|
{isRemoving ? 'Removing…' : 'Remove Listing'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,29 @@ export function filterAdminHidden<
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `filterAdminHidden`, but for a mixed-kind list where each item carries
|
||||||
|
* its own source `event` (e.g. Lab profiles, which span 10 different kinds).
|
||||||
|
* The kind is read from `it.event.kind`.
|
||||||
|
*/
|
||||||
|
export function filterAdminHiddenMixed<
|
||||||
|
T extends {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
identifier: string;
|
||||||
|
event: { kind: number };
|
||||||
|
},
|
||||||
|
>(items: T[], hidden: AdminHiddenSet): T[] {
|
||||||
|
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
|
||||||
|
return items.filter(
|
||||||
|
(it) =>
|
||||||
|
!hidden.has({
|
||||||
|
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`,
|
||||||
|
id: it.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useAdminModeration() {
|
export function useAdminModeration() {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types';
|
import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
export function useCoaches(limit = 100) {
|
export function useCoaches(limit = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['coaches', limit, getQueryKeySuffix()],
|
queryKey: ['coaches', limit, getQueryKeySuffix()],
|
||||||
|
select: (coaches: SkateCoach[]) => filterAdminHidden(coaches, SKATE_COACH_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
@ -42,9 +45,14 @@ export function useCoaches(limit = 100) {
|
||||||
|
|
||||||
export function useCoach(identifier: string, pubkey?: string) {
|
export function useCoach(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()],
|
queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()],
|
||||||
|
select: (coach: SkateCoach | null) =>
|
||||||
|
coach && hidden.has({ coord: `${SKATE_COACH_KIND}:${coach.pubkey}:${coach.identifier}`, id: coach.id })
|
||||||
|
? null
|
||||||
|
: coach,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
export type LabProfileType =
|
export type LabProfileType =
|
||||||
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
||||||
|
|
@ -82,9 +83,11 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
|
||||||
|
|
||||||
export function useLabProfiles(limit = 100) {
|
export function useLabProfiles(limit = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
|
queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
|
||||||
|
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(15000);
|
const signal = AbortSignal.timeout(15000);
|
||||||
|
|
||||||
|
|
@ -120,9 +123,17 @@ export function useLabProfiles(limit = 100) {
|
||||||
|
|
||||||
export function useLabProfile(identifier: string, pubkey?: string) {
|
export function useLabProfile(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()],
|
queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()],
|
||||||
|
select: (profile: LabProfile | null) =>
|
||||||
|
profile && hidden.has({
|
||||||
|
coord: `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`,
|
||||||
|
id: profile.id,
|
||||||
|
})
|
||||||
|
? null
|
||||||
|
: profile,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||||
|
|
@ -8,9 +9,11 @@ const TIMEOUT = 8000;
|
||||||
|
|
||||||
export function useMarketplaceItems(limit = 100) {
|
export function useMarketplaceItems(limit = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
|
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
|
||||||
|
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
@ -41,9 +44,11 @@ export function useMarketplaceItems(limit = 100) {
|
||||||
|
|
||||||
export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) {
|
export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
|
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
|
||||||
|
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { useGigs } from '@/hooks/useGigs';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
||||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
|
|
@ -575,17 +576,20 @@ export default function CoachDetailPage() {
|
||||||
accentBg={`${accent.bg} hover:opacity-90`}
|
accentBg={`${accent.bg} hover:opacity-90`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Owner controls — delete your own Lab profile */}
|
{/* Owner controls — delete your own Lab profile; admins can take
|
||||||
{user && user.pubkey === profile.pubkey && (
|
down any profile (DeleteEntityDialog gates owner-or-admin). */}
|
||||||
|
{user && (user.pubkey === profile.pubkey || isAdminPubkey(user.pubkey)) && (
|
||||||
<Card className="bg-black/90 border-2 border-red-500/30">
|
<Card className="bg-black/90 border-2 border-red-500/30">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg text-red-400 uppercase font-black">
|
<CardTitle className="text-lg text-red-400 uppercase font-black">
|
||||||
Manage
|
{user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Remove your {typeNoun} profile from The Lab.
|
{user.pubkey === profile.pubkey
|
||||||
|
? `Remove your ${typeNoun} profile from The Lab.`
|
||||||
|
: `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}
|
||||||
</p>
|
</p>
|
||||||
<DeleteEntityDialog
|
<DeleteEntityDialog
|
||||||
event={profile.event}
|
event={profile.event}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue