Compare commits
10 commits
74bd140295
...
2dcf5a2aad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcf5a2aad | ||
|
|
50c95d0ff7 | ||
|
|
4ff736734d | ||
|
|
fd1713f6b1 | ||
|
|
6c9b6fab02 | ||
|
|
0ae4c59630 | ||
|
|
ab11b0d3ed | ||
|
|
0355c10dbc | ||
|
|
28d34e3fbe | ||
|
|
b0df599b52 |
11 changed files with 176 additions and 55 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -2228,7 +2228,7 @@ session with **no other memory of this project**. So:
|
||||||
instead of showing the error boundary.
|
instead of showing the error boundary.
|
||||||
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
||||||
**156 tests**, build ok. The gate also type-checks the CVM server
|
**156 tests**, build ok. The gate also type-checks the CVM server
|
||||||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.57**.
|
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.61**.
|
||||||
- ✅ 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. Non-component exports were
|
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||||||
|
|
@ -2785,6 +2785,23 @@ Rules:
|
||||||
profile; the adjacent chevron opens the account menu. Removed the
|
profile; the adjacent chevron opens the account menu. Removed the
|
||||||
redundant “IN” label so the header fits at 390px. APK sha256 `846efb24…`,
|
redundant “IN” label so the header fits at 390px. APK sha256 `846efb24…`,
|
||||||
versionCode 10757. Web live at `5026b63`.
|
versionCode 10757. Web live at `5026b63`.
|
||||||
|
- **1.7.58 (2026-10-30):** the top-left SkateSpots brand lockup opens the
|
||||||
|
signed-in user’s profile on mobile and desktop; signed out it still opens
|
||||||
|
the map. Bottom Spots / desktop Map remains the home shortcut. APK sha256
|
||||||
|
`0d7bdad7…`, versionCode 10758. Web live at `74bd140`.
|
||||||
|
- **1.7.59 (2026-10-30):** owner/admin Delete in spot/shop card menus; mobile
|
||||||
|
Back arrow on every non-map screen (history fallback to map); avatar account
|
||||||
|
menu trimmed to status/profile/settings/accounts/logout; brand wordmark
|
||||||
|
hidden on sub-pages for narrow-header fit. APK sha256 `0fd4a149…`,
|
||||||
|
versionCode 10759. Web live at `0355c10`.
|
||||||
|
- **1.7.60 (2026-10-30):** Cordn header shows last successful check; sellers
|
||||||
|
see payment-readiness in Create Listing + offers; uploads resize photos to
|
||||||
|
1600px WebP when smaller (GIF/undecodable formats untouched), cutting
|
||||||
|
quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760.
|
||||||
|
Web live at `6c9b6fa`.
|
||||||
|
- **1.7.61 (2026-10-30):** Cordn setup timeline now checks prepared device
|
||||||
|
/sent request and spins on the approval step while waiting. APK sha256
|
||||||
|
`db0279f7…`, versionCode 10761. Web live at `50c95d0`.
|
||||||
|
|
||||||
### Relay archive (dataset backup)
|
### Relay archive (dataset backup)
|
||||||
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.58",
|
"version": "1.7.61",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.58",
|
"version": "1.7.61",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.58",
|
"version": "1.7.61",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.org",
|
"homepage": "https://skate-spots.org",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,35 @@
|
||||||
|
🧩 Cordn setup is easier to follow
|
||||||
|
- The setup steps now show progress: preparing this device is checked once the
|
||||||
|
key package is ready, and the approval step shows a spinner while waiting.
|
||||||
|
- The private-chat header already shows the last successful check, so “live” and
|
||||||
|
“still waiting” are distinguishable at a glance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚡ Cordn status, payment readiness, lighter photos
|
||||||
|
- Cordn panels now show the last successful check next to the connection status,
|
||||||
|
so “waiting” and “live” are easier to tell apart.
|
||||||
|
- Sellers see a payment-readiness card in listing creation and on incoming
|
||||||
|
offers, with a direct link to add a Lightning address before accepting a deal.
|
||||||
|
- Photos are resized to max 1600px and converted to WebP before upload when that
|
||||||
|
makes them smaller. GIFs and formats the WebView cannot decode are left alone.
|
||||||
|
This cuts upload time, quota use and Blossom storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🧹 Spot owners can delete from the card menu
|
||||||
|
- Owner/admin members now get a **Delete** action in the three-dot menu on
|
||||||
|
spot and shop cards, opening the existing confirmation dialog.
|
||||||
|
- Added a mobile **Back** arrow on every non-map screen, with history fallback
|
||||||
|
to the map. The bottom Spots tab remains the home shortcut.
|
||||||
|
- Trimmed the avatar account menu to purpose-built actions: status, profile,
|
||||||
|
settings, account switching and logout. The larger navigation stays in the
|
||||||
|
burger menu.
|
||||||
|
- Brand wordmark hides on sub-pages so the Back arrow and header controls fit
|
||||||
|
on narrow phones without truncation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
🧭 Brand logo opens your profile
|
🧭 Brand logo opens your profile
|
||||||
- The top-left SkateSpots lockup now opens your public profile when signed in
|
- The top-left SkateSpots lockup now opens your public profile when signed in
|
||||||
(both mobile and desktop). Signed out, it still goes to the map.
|
(both mobile and desktop). Signed out, it still goes to the map.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isNativePlatform } from '@/lib/platform';
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
|
@ -39,7 +39,6 @@ import {
|
||||||
User,
|
User,
|
||||||
Plus,
|
Plus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
|
||||||
Package,
|
Package,
|
||||||
Heart,
|
Heart,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
|
@ -53,6 +52,7 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ArrowLeft,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||||
|
|
@ -283,12 +283,6 @@ function MobileHeaderUserMenu({
|
||||||
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
||||||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Plus className="h-4 w-4 text-[#ccff00]" />
|
|
||||||
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
|
|
@ -301,36 +295,6 @@ function MobileHeaderUserMenu({
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Database className="h-4 w-4 text-[#ccff00]" />
|
|
||||||
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Package className="h-4 w-4 text-purple-400" />
|
|
||||||
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Wallet className="h-4 w-4 text-orange-400" />
|
|
||||||
<span className="text-orange-400">{t('nav.wallet')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
|
|
||||||
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Package className="h-4 w-4 text-orange-400" />
|
|
||||||
<span className="text-orange-400">{t('nav.marketplace')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
@ -512,6 +476,8 @@ function MobileNav({
|
||||||
|
|
||||||
export function Layout({ children, noPadding }: LayoutProps) {
|
export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const showMobileBack = location.pathname !== '/' && location.pathname !== '/map';
|
||||||
// The map is full-bleed (it manages its own height), so it gets no bottom
|
// The map is full-bleed (it manages its own height), so it gets no bottom
|
||||||
// padding even though it shares this one persistent layout with every route.
|
// padding even though it shares this one persistent layout with every route.
|
||||||
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
||||||
|
|
@ -842,6 +808,22 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
|
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
|
||||||
<div className="flex items-center justify-between h-14 px-3 gap-3">
|
<div className="flex items-center justify-between h-14 px-3 gap-3">
|
||||||
|
{showMobileBack && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="-ml-1 h-9 w-9 shrink-0 text-gray-300 hover:bg-white/5 hover:text-white"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
title={t('common.back')}
|
||||||
|
onClick={() => {
|
||||||
|
if (location.key !== 'default') navigate(-1);
|
||||||
|
else navigate('/');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
|
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
|
||||||
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
|
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
|
||||||
|
|
@ -853,7 +835,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 truncate font-display text-lg neon-red tracking-wide" style={{
|
<span className={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
|
||||||
textShadow: '0 0 8px #ff0040'
|
textShadow: '0 0 8px #ff0040'
|
||||||
}}>SKATESPOTS</span>
|
}}>SKATESPOTS</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
import { PayWithSats } from '@/components/PayWithSats';
|
import { PayWithSats } from '@/components/PayWithSats';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -183,6 +184,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{isOwner && <PaymentReadinessNote />}
|
||||||
|
|
||||||
{mine.map((thread) => {
|
{mine.map((thread) => {
|
||||||
const latest = thread.latest;
|
const latest = thread.latest;
|
||||||
const other = latest.author === user.pubkey
|
const other = latest.author === user.pubkey
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function PaymentReadinessNote() {
|
||||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
||||||
<span>
|
<span>
|
||||||
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
||||||
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
|
<Link to="/settings?section=profile" className="text-yellow-400 underline hover:text-yellow-300">
|
||||||
{t('marketUi.paymentReadiness.nostrProfile')}
|
{t('marketUi.paymentReadiness.nostrProfile')}
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
{t('marketUi.paymentReadiness.notReadyAfter')}
|
{t('marketUi.paymentReadiness.notReadyAfter')}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
|
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
|
|
@ -23,6 +23,7 @@ import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||||||
|
|
@ -30,6 +31,7 @@ import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||||
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SpotCardProps {
|
interface SpotCardProps {
|
||||||
|
|
@ -48,10 +50,13 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||||||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// Determine if this is a shop or a spot
|
// Determine if this is a shop or a spot
|
||||||
const isShop = spot.types.includes('shop');
|
const isShop = spot.types.includes('shop');
|
||||||
const isOwner = user?.pubkey === spot.pubkey;
|
const isOwner = user?.pubkey === spot.pubkey;
|
||||||
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||||
|
const canDelete = !!user && (isOwner || isAdmin);
|
||||||
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
||||||
|
|
||||||
// Shops that actually issue access passes get a badge (one shared, cached query).
|
// Shops that actually issue access passes get a badge (one shared, cached query).
|
||||||
|
|
@ -245,7 +250,19 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
{canDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}}
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{isOwner ? t('common.delete') : t('spotUi.removeAdmin')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{!isOwner && (
|
{!isOwner && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -275,6 +292,17 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Owner/admin delete confirmation */}
|
||||||
|
{canDelete && (
|
||||||
|
<DeleteEntityDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOpenChange={setShowDeleteDialog}
|
||||||
|
event={spot.event}
|
||||||
|
entityLabel={isShop ? t('mapUi.skateShop') : t('spotUi.spot')}
|
||||||
|
entityName={spot.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Report Dialog */}
|
{/* Report Dialog */}
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
open={showReportDialog}
|
open={showReportDialog}
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,11 @@ export function CordnNativePanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
|
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
|
||||||
|
{native.group && native.lastCheckedAt && (
|
||||||
|
<p className="pt-0.5 text-[10px] text-gray-600">
|
||||||
|
{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{!isCommunity && (
|
{!isCommunity && (
|
||||||
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
|
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
|
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
|
||||||
|
|
@ -870,14 +875,29 @@ export function CordnNativePanel({
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol className="mx-auto max-w-sm space-y-2 text-left">
|
<ol className="mx-auto max-w-sm space-y-2 text-left">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => {
|
||||||
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
|
// Members see the setup move visibly: device key prepared →
|
||||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300">
|
// request sent → waiting for an admin. Founders start at step 1.
|
||||||
{index + 1}
|
const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady);
|
||||||
</span>
|
const stepWaiting = !canCreate && index === 1 && awaiting;
|
||||||
<span>{step}</span>
|
return (
|
||||||
</li>
|
<li
|
||||||
))}
|
key={step}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 text-xs',
|
||||||
|
stepDone || stepWaiting ? 'text-teal-200' : 'text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
|
||||||
|
stepDone ? 'bg-teal-500/20 text-teal-300' : 'bg-teal-500/15 text-teal-300',
|
||||||
|
)}>
|
||||||
|
{stepDone ? <Check className="h-2.5 w-2.5" /> : stepWaiting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{step}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
|
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,41 @@ function validateImage(file: File): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize/compress camera photos before upload. Most phone photos are far larger
|
||||||
|
* than the app ever displays; 1600px WebP cuts upload time and Blossom storage
|
||||||
|
* without touching GIFs or formats the WebView cannot decode.
|
||||||
|
*/
|
||||||
|
async function prepareImage(file: File): Promise<File> {
|
||||||
|
const type = (file.type || '').toLowerCase();
|
||||||
|
if (type === 'image/gif' || type === 'image/heic' || type === 'image/heif') return file;
|
||||||
|
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||||
|
// Already small enough: keep the original bytes/format.
|
||||||
|
if (scale === 1 && file.size <= 1_200_000) {
|
||||||
|
bitmap.close();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) { bitmap.close(); return file; }
|
||||||
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
bitmap.close();
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/webp', 0.84);
|
||||||
|
});
|
||||||
|
if (!blob || blob.size >= file.size) return file;
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||||
|
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
|
||||||
|
} catch {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hostOf(url: string): string {
|
function hostOf(url: string): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname;
|
return new URL(url).hostname;
|
||||||
|
|
@ -65,6 +100,7 @@ export function useUploadFile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
validateImage(file);
|
validateImage(file);
|
||||||
|
const prepared = await prepareImage(file);
|
||||||
|
|
||||||
const uploader = new BlossomUploader({
|
const uploader = new BlossomUploader({
|
||||||
servers: UPLOAD_SERVERS,
|
servers: UPLOAD_SERVERS,
|
||||||
|
|
@ -73,7 +109,7 @@ export function useUploadFile() {
|
||||||
|
|
||||||
let tags;
|
let tags;
|
||||||
try {
|
try {
|
||||||
tags = await uploader.upload(file);
|
tags = await uploader.upload(prepared);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(uploadErrorMessage(error));
|
throw new Error(uploadErrorMessage(error));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useListingRestrictions, recordListingCreation } from '@/hooks/useListin
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { Nip05Required } from '@/components/Nip05Required';
|
import { Nip05Required } from '@/components/Nip05Required';
|
||||||
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
||||||
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
||||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
|
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
|
||||||
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
||||||
|
|
@ -857,6 +858,8 @@ export default function CreateListingPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{listingType === 'selling' && <PaymentReadinessNote />}
|
||||||
|
|
||||||
{priceType === 'fixed' && (
|
{priceType === 'fixed' && (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="col-span-2 space-y-2">
|
<div className="col-span-2 space-y-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue