an app to share curated trails sidetrail.app
atproto nextjs react rsc

make things more transitioney

+628 -407
+2 -18
app/(home)/drafts/DraftsClientPage.tsx
··· 1 "use client"; 2 3 - import { useRouter } from "next/navigation"; 4 - import { useTransition, useEffect } from "react"; 5 import { deleteDraft } from "@/data/drafts/actions"; 6 import { HomeWalkingPill } from "../walking/HomeWalkingPill"; 7 import { HomeEmptyState } from "@/app/HomeEmptyState"; ··· 12 }; 13 14 export function DraftsClientPage({ initialDrafts }: Props) { 15 - const router = useRouter(); 16 - const [, startTransition] = useTransition(); 17 - 18 - // With Cache Components + Activity, effects are recreated when page becomes visible. 19 - // Refresh data on every activation to ensure freshness. 20 - useEffect(() => { 21 - startTransition(() => { 22 - router.refresh(); 23 - }); 24 - }, [router, startTransition]); 25 - 26 - const handleDelete = async (rkey: string) => { 27 await deleteDraft(rkey); 28 - startTransition(() => { 29 - router.refresh(); 30 - }); 31 }; 32 33 if (initialDrafts.length === 0) { ··· 45 backgroundColor={draft.backgroundColor} 46 linkTo={`/drafts/${draft.rkey}`} 47 dots={Array(draft.stopsCount).fill("upcoming")} 48 - onDelete={() => handleDelete(draft.rkey)} 49 deleteLabel="delete draft" 50 deleteConfirmMessage="delete this draft?" 51 />
··· 1 "use client"; 2 3 import { deleteDraft } from "@/data/drafts/actions"; 4 import { HomeWalkingPill } from "../walking/HomeWalkingPill"; 5 import { HomeEmptyState } from "@/app/HomeEmptyState"; ··· 10 }; 11 12 export function DraftsClientPage({ initialDrafts }: Props) { 13 + const deleteAction = async (rkey: string) => { 14 await deleteDraft(rkey); 15 }; 16 17 if (initialDrafts.length === 0) { ··· 29 backgroundColor={draft.backgroundColor} 30 linkTo={`/drafts/${draft.rkey}`} 31 dots={Array(draft.stopsCount).fill("upcoming")} 32 + deleteAction={() => deleteAction(draft.rkey)} 33 deleteLabel="delete draft" 34 deleteConfirmMessage="delete this draft?" 35 />
+2 -6
app/(home)/walking/HomeWalkingList.tsx
··· 1 "use client"; 2 3 - import { useRouter } from "next/navigation"; 4 import type { WalkCardData } from "@/data/queries"; 5 import { HomeWalkingPill } from "./HomeWalkingPill"; 6 import { abandonWalk } from "@/data/actions"; ··· 12 }; 13 14 export function HomeWalkingList({ walks, canDelete = true }: Props) { 15 - const router = useRouter(); 16 - 17 - const handleAbandon = async (walkUri: string) => { 18 await abandonWalk(walkUri); 19 - router.refresh(); 20 }; 21 22 return ( ··· 60 backgroundColor={walk.backgroundColor} 61 linkTo={`/@${walk.trailCreatorHandle}/trail/${walk.trailRkey}`} 62 dots={dots} 63 - onDelete={canDelete ? () => handleAbandon(walkUri) : undefined} 64 deleteLabel={canDelete ? "abandon trail" : undefined} 65 /> 66 );
··· 1 "use client"; 2 3 import type { WalkCardData } from "@/data/queries"; 4 import { HomeWalkingPill } from "./HomeWalkingPill"; 5 import { abandonWalk } from "@/data/actions"; ··· 11 }; 12 13 export function HomeWalkingList({ walks, canDelete = true }: Props) { 14 + const abandonAction = async (walkUri: string) => { 15 await abandonWalk(walkUri); 16 }; 17 18 return ( ··· 56 backgroundColor={walk.backgroundColor} 57 linkTo={`/@${walk.trailCreatorHandle}/trail/${walk.trailRkey}`} 58 dots={dots} 59 + deleteAction={canDelete ? () => abandonAction(walkUri) : undefined} 60 deleteLabel={canDelete ? "abandon trail" : undefined} 61 /> 62 );
+26 -56
app/(home)/walking/HomeWalkingPill.css
··· 3 } 4 5 .HomeWalkingPill { 6 - text-decoration: none; 7 - display: block; 8 - position: relative; 9 - } 10 - 11 - .HomeWalkingPill-bg { 12 display: flex; 13 - align-items: flex-start; 14 - justify-content: space-between; 15 - gap: 1.5rem; 16 - padding: 1.5rem; 17 - border-radius: 12px; 18 - transition: all 0.2s ease; 19 - position: relative; 20 - isolation: isolate; 21 - } 22 - 23 - .HomeWalkingPill-bg::before { 24 - content: ""; 25 - position: absolute; 26 - inset: 0; 27 - background: var(--bg-color); 28 - border: 1.5px solid; 29 - border-color: color-mix(in srgb, var(--accent-color) 15%, rgba(0, 0, 0, 0.08)); 30 - border-radius: 12px; 31 - filter: var(--user-content-filter); 32 - z-index: -1; 33 - transition: all 0.2s ease; 34 - pointer-events: none; 35 - } 36 - 37 - @media (hover: hover) { 38 - .HomeWalkingPill:hover .HomeWalkingPill-bg { 39 - transform: translateY(-2px); 40 - } 41 - 42 - .HomeWalkingPill:hover .HomeWalkingPill-bg::before { 43 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 44 - border-color: var(--accent-color); 45 - } 46 - } 47 - 48 - .HomeWalkingPill:active .HomeWalkingPill-bg::before { 49 - border-color: var(--accent-color); 50 - transition-duration: 0.05s; 51 - } 52 - 53 - .HomeWalkingPill-content { 54 - flex: 1; 55 - min-width: 0; 56 - } 57 - 58 - .HomeWalkingPill-header { 59 - margin-bottom: 0.375rem; 60 } 61 62 .HomeWalkingPill-title { ··· 67 text-transform: lowercase; 68 letter-spacing: -0.01em; 69 filter: var(--user-content-filter); 70 } 71 72 .HomeWalkingPill-subtitle { 73 font-size: 0.8125rem; 74 color: var(--text-tertiary); 75 text-transform: lowercase; 76 - margin-bottom: 1rem; 77 } 78 79 .HomeWalkingPill-progress { ··· 169 170 @media (hover: hover) { 171 .HomeWalkingPill-deleteButton:hover { 172 - color: var(--text-secondary); 173 background: rgba(0, 0, 0, 0.05); 174 } 175 } ··· 181 } 182 183 .HomeWalkingPill-deleteButton:active { 184 - color: var(--text-secondary); 185 background: rgba(0, 0, 0, 0.08); 186 transition-duration: 0.05s; 187 } ··· 191 background: rgba(255, 255, 255, 0.08); 192 } 193 }
··· 3 } 4 5 .HomeWalkingPill { 6 display: flex; 7 + flex-direction: column; 8 + gap: 0.375rem; 9 } 10 11 .HomeWalkingPill-title { ··· 16 text-transform: lowercase; 17 letter-spacing: -0.01em; 18 filter: var(--user-content-filter); 19 + margin: 0; 20 } 21 22 .HomeWalkingPill-subtitle { 23 font-size: 0.8125rem; 24 color: var(--text-tertiary); 25 text-transform: lowercase; 26 + margin: 0 0 0.625rem 0; 27 } 28 29 .HomeWalkingPill-progress { ··· 119 120 @media (hover: hover) { 121 .HomeWalkingPill-deleteButton:hover { 122 + color: var(--text-primary); 123 background: rgba(0, 0, 0, 0.05); 124 } 125 } ··· 131 } 132 133 .HomeWalkingPill-deleteButton:active { 134 + color: var(--text-primary); 135 background: rgba(0, 0, 0, 0.08); 136 transition-duration: 0.05s; 137 } ··· 141 background: rgba(255, 255, 255, 0.08); 142 } 143 } 144 + 145 + .HomeWalkingPill-deleteButton--pending { 146 + cursor: pointer; 147 + pointer-events: none; 148 + animation: deleteButton-pulse 2s ease-in-out infinite; 149 + } 150 + 151 + @keyframes deleteButton-pulse { 152 + 0%, 153 + 20% { 154 + opacity: 1; 155 + } 156 + 50% { 157 + opacity: 0.7; 158 + } 159 + 80%, 160 + 100% { 161 + opacity: 1; 162 + } 163 + }
+26 -31
app/(home)/walking/HomeWalkingPill.tsx
··· 1 - import Link from "next/link"; 2 import "./HomeWalkingPill.css"; 3 4 type ProgressDotState = "completed" | "current" | "upcoming"; ··· 10 backgroundColor: string; 11 linkTo: string; 12 dots: ProgressDotState[]; 13 - onDelete?: () => void; 14 deleteLabel?: string; 15 deleteConfirmMessage?: string; 16 }; ··· 22 backgroundColor, 23 linkTo, 24 dots, 25 - onDelete, 26 deleteLabel, 27 deleteConfirmMessage = "abandon this trail? your progress will be lost", 28 }: Props) { 29 const handleDelete = (e: React.MouseEvent) => { 30 e.preventDefault(); 31 e.stopPropagation(); 32 if (confirm(deleteConfirmMessage)) { 33 - onDelete?.(); 34 } 35 }; 36 37 return ( 38 <div className="HomeWalkingPill-wrapper"> 39 - <Link href={linkTo} className="HomeWalkingPill"> 40 - <div 41 - className="HomeWalkingPill-bg" 42 - style={ 43 - { 44 - "--accent-color": accentColor, 45 - "--bg-color": backgroundColor, 46 - "--accent-color-transparent": `${accentColor}20`, 47 - } as React.CSSProperties 48 - } 49 - > 50 - <div className="HomeWalkingPill-content"> 51 - <div className="HomeWalkingPill-header"> 52 - <span className="HomeWalkingPill-title">{title}</span> 53 - </div> 54 - <div className="HomeWalkingPill-subtitle">{subtitle}</div> 55 - <div className="HomeWalkingPill-progress"> 56 - {dots.map((state, idx) => ( 57 - <div key={idx} className="HomeWalkingPill-dotWrapper"> 58 - <div className={`HomeWalkingPill-dot HomeWalkingPill-dot--${state}`} /> 59 - {idx < dots.length - 1 && <div className="HomeWalkingPill-line" />} 60 - </div> 61 - ))} 62 - </div> 63 </div> 64 </div> 65 - </Link> 66 - {onDelete && ( 67 <button 68 onClick={handleDelete} 69 - className="HomeWalkingPill-deleteButton" 70 aria-label={deleteLabel} 71 > 72 × 73 </button>
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { Card } from "@/components/Card"; 5 import "./HomeWalkingPill.css"; 6 7 type ProgressDotState = "completed" | "current" | "upcoming"; ··· 13 backgroundColor: string; 14 linkTo: string; 15 dots: ProgressDotState[]; 16 + deleteAction?: () => Promise<void> | void; 17 deleteLabel?: string; 18 deleteConfirmMessage?: string; 19 }; ··· 25 backgroundColor, 26 linkTo, 27 dots, 28 + deleteAction, 29 deleteLabel, 30 deleteConfirmMessage = "abandon this trail? your progress will be lost", 31 }: Props) { 32 + const [isPending, startTransition] = useTransition(); 33 + 34 const handleDelete = (e: React.MouseEvent) => { 35 e.preventDefault(); 36 e.stopPropagation(); 37 if (confirm(deleteConfirmMessage)) { 38 + startTransition(async () => { 39 + await deleteAction?.(); 40 + }); 41 } 42 }; 43 44 return ( 45 <div className="HomeWalkingPill-wrapper"> 46 + <Card href={linkTo} accentColor={accentColor} backgroundColor={backgroundColor}> 47 + <div className="HomeWalkingPill"> 48 + <h3 className="HomeWalkingPill-title">{title}</h3> 49 + <p className="HomeWalkingPill-subtitle">{subtitle}</p> 50 + <div className="HomeWalkingPill-progress"> 51 + {dots.map((state, idx) => ( 52 + <div key={idx} className="HomeWalkingPill-dotWrapper"> 53 + <div className={`HomeWalkingPill-dot HomeWalkingPill-dot--${state}`} /> 54 + {idx < dots.length - 1 && <div className="HomeWalkingPill-line" />} 55 + </div> 56 + ))} 57 </div> 58 </div> 59 + </Card> 60 + {deleteAction && ( 61 <button 62 onClick={handleDelete} 63 + className={`HomeWalkingPill-deleteButton${isPending ? " HomeWalkingPill-deleteButton--pending" : ""}`} 64 aria-label={deleteLabel} 65 + disabled={isPending} 66 > 67 × 68 </button>
+1 -1
app/FloatingAvatar.css
··· 6 border: 2px solid rgba(255, 255, 255, 0.95); 7 box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); 8 opacity: 0.8; 9 - cursor: default; 10 animation: FloatingAvatar-floatNatural 6s ease-in-out infinite; 11 } 12
··· 6 border: 2px solid rgba(255, 255, 255, 0.95); 7 box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); 8 opacity: 0.8; 9 + cursor: inherit; 10 animation: FloatingAvatar-floatNatural 6s ease-in-out infinite; 11 } 12
+21 -11
app/FloatingAvatar.tsx
··· 9 title: string; 10 contained?: boolean; 11 opaque?: boolean; 12 } 13 14 export function FloatingAvatar({ ··· 17 title, 18 contained = false, 19 opaque = false, 20 }: FloatingAvatarProps) { 21 if (!src) return null; 22 ··· 50 const timingFunctions = ["ease-in-out", "ease-in", "ease-out", "linear"]; 51 const timingFunction = timingFunctions[Math.floor(random(2) * timingFunctions.length)]; 52 53 return ( 54 <Link 55 href={`/@${handle}/walking`} ··· 60 className="FloatingAvatar-link" 61 tabIndex={-1} 62 > 63 - <img 64 - src={src} 65 - alt={handle} 66 - title={title} 67 - className={`FloatingAvatar ${contained ? "FloatingAvatar-contained" : ""} ${opaque ? "FloatingAvatar-opaque" : ""} FloatingAvatar-clickable`} 68 - style={{ 69 - animationDuration: `${duration}s`, 70 - animationDelay: `${delay}s`, 71 - animationTimingFunction: timingFunction, 72 - }} 73 - /> 74 </Link> 75 ); 76 }
··· 9 title: string; 10 contained?: boolean; 11 opaque?: boolean; 12 + noLink?: boolean; 13 } 14 15 export function FloatingAvatar({ ··· 18 title, 19 contained = false, 20 opaque = false, 21 + noLink = false, 22 }: FloatingAvatarProps) { 23 if (!src) return null; 24 ··· 52 const timingFunctions = ["ease-in-out", "ease-in", "ease-out", "linear"]; 53 const timingFunction = timingFunctions[Math.floor(random(2) * timingFunctions.length)]; 54 55 + const imgElement = ( 56 + <img 57 + src={src} 58 + alt={handle} 59 + title={title} 60 + className={`FloatingAvatar ${contained ? "FloatingAvatar-contained" : ""} ${opaque ? "FloatingAvatar-opaque" : ""} ${noLink ? "" : "FloatingAvatar-clickable"}`} 61 + style={{ 62 + animationDuration: `${duration}s`, 63 + animationDelay: `${delay}s`, 64 + animationTimingFunction: timingFunction, 65 + }} 66 + /> 67 + ); 68 + 69 + if (noLink) { 70 + return imgElement; 71 + } 72 + 73 return ( 74 <Link 75 href={`/@${handle}/walking`} ··· 80 className="FloatingAvatar-link" 81 tabIndex={-1} 82 > 83 + {imgElement} 84 </Link> 85 ); 86 }
+2 -1
app/HomeTrailsList.tsx
··· 1 import type { TrailCardData } from "../data/queries"; 2 import { TrailCard } from "./TrailCard"; 3 import { HomeEmptyState } from "./HomeEmptyState"; 4 import "./HomeTrailsList.css"; 5 ··· 38 {reordered.map(({ item: trail, originalIndex }) => ( 39 <div key={trail.uri} style={{ "--original-index": originalIndex } as React.CSSProperties}> 40 <TrailCard 41 - uri={trail.uri} 42 rkey={trail.rkey} 43 creatorHandle={trail.creatorHandle} 44 title={trail.title} ··· 47 backgroundColor={trail.backgroundColor} 48 creator={trail.creator} 49 stopsCount={trail.stopsCount} 50 /> 51 </div> 52 ))}
··· 1 import type { TrailCardData } from "../data/queries"; 2 import { TrailCard } from "./TrailCard"; 3 + import { TrailCardWalkers } from "./TrailCardWalkers"; 4 import { HomeEmptyState } from "./HomeEmptyState"; 5 import "./HomeTrailsList.css"; 6 ··· 39 {reordered.map(({ item: trail, originalIndex }) => ( 40 <div key={trail.uri} style={{ "--original-index": originalIndex } as React.CSSProperties}> 41 <TrailCard 42 rkey={trail.rkey} 43 creatorHandle={trail.creatorHandle} 44 title={trail.title} ··· 47 backgroundColor={trail.backgroundColor} 48 creator={trail.creator} 49 stopsCount={trail.stopsCount} 50 + walkersSlot={<TrailCardWalkers trailUri={trail.uri} />} 51 /> 52 </div> 53 ))}
+8 -11
app/NewTrailButton.tsx
··· 1 "use client"; 2 3 - import { useTransition } from "react"; 4 import { useRouter } from "next/navigation"; 5 - import "./NewTrailButton.css"; 6 import { createDraft } from "@/data/drafts/actions"; 7 import { useAuthAction } from "@/auth/useAuthAction"; 8 9 interface NewTrailButtonProps { 10 text?: string; ··· 13 export function NewTrailButton({ text = "+ new trail" }: NewTrailButtonProps) { 14 const router = useRouter(); 15 const requireAuth = useAuthAction(); 16 - const [isPending, startTransition] = useTransition(); 17 18 - const handleClick = () => { 19 requireAuth(); 20 - startTransition(async () => { 21 - const rkey = await createDraft(); 22 - router.push(`/drafts/${rkey}`); 23 - }); 24 }; 25 26 return ( 27 - <button onClick={handleClick} className="NewTrailButton" disabled={isPending}> 28 - {isPending ? "creating..." : text} 29 - </button> 30 ); 31 }
··· 1 "use client"; 2 3 import { useRouter } from "next/navigation"; 4 + import { ActionButton } from "@/components/ActionButton"; 5 import { createDraft } from "@/data/drafts/actions"; 6 import { useAuthAction } from "@/auth/useAuthAction"; 7 + import "./NewTrailButton.css"; 8 9 interface NewTrailButtonProps { 10 text?: string; ··· 13 export function NewTrailButton({ text = "+ new trail" }: NewTrailButtonProps) { 14 const router = useRouter(); 15 const requireAuth = useAuthAction(); 16 17 + const createAction = async () => { 18 requireAuth(); 19 + const rkey = await createDraft(); 20 + router.push(`/drafts/${rkey}`); 21 }; 22 23 return ( 24 + <ActionButton action={createAction} className="NewTrailButton" pendingChildren="creating..."> 25 + {text} 26 + </ActionButton> 27 ); 28 }
+20 -9
app/SegmentTabs.css
··· 26 font-size: 1.125rem; 27 color: var(--text-tertiary); 28 cursor: pointer; 29 - transition: color 0.2s ease; 30 text-transform: lowercase; 31 font-weight: 400; 32 font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; ··· 35 } 36 37 @media (hover: hover) { 38 - .SegmentTabs-tab:hover:not(.SegmentTabs-tab--active) { 39 color: var(--text-secondary); 40 } 41 } 42 43 - .SegmentTabs-tab:active:not(.SegmentTabs-tab--active) { 44 - color: var(--text-secondary); 45 - transition-duration: 0.05s; 46 - } 47 - 48 - .SegmentTabs-tab--active { 49 color: var(--text-primary); 50 - font-weight: 500; 51 } 52 53 @media (max-width: 480px) { ··· 56 white-space: nowrap; 57 } 58 }
··· 26 font-size: 1.125rem; 27 color: var(--text-tertiary); 28 cursor: pointer; 29 text-transform: lowercase; 30 font-weight: 400; 31 font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; ··· 34 } 35 36 @media (hover: hover) { 37 + .SegmentTabs-tab:hover { 38 color: var(--text-secondary); 39 } 40 + .SegmentTabs-tab--active:hover { 41 + color: var(--text-primary); 42 + } 43 } 44 45 + .SegmentTabs-tab--active, 46 + .SegmentTabs-tabText--pending { 47 color: var(--text-primary); 48 } 49 50 @media (max-width: 480px) { ··· 53 white-space: nowrap; 54 } 55 } 56 + 57 + .SegmentTabs-tabText--pending { 58 + animation: segmentTab-pulse 2s 200ms ease-in-out infinite; 59 + } 60 + 61 + @keyframes segmentTab-pulse { 62 + 0%, 63 + 100% { 64 + opacity: 1; 65 + } 66 + 50% { 67 + opacity: 0.8; 68 + } 69 + }
+38 -11
app/SegmentTabs.tsx
··· 1 "use client"; 2 3 import { useSelectedLayoutSegment } from "next/navigation"; 4 - import Link from "next/link"; 5 import "./SegmentTabs.css"; 6 7 interface SegmentTabsProps { ··· 12 href?: string; 13 }>; 14 basePath?: string; 15 } 16 17 export function SegmentTabs({ segments, basePath = "/" }: SegmentTabsProps) { 18 const selected = useSelectedLayoutSegment(); 19 return ( 20 <nav className="SegmentTabs"> 21 - {segments.map((segment) => ( 22 - <Link 23 - key={segment.segment} 24 - href={segment.href ?? basePath + (segment.segment ?? "")} 25 - className={`SegmentTabs-tab ${selected === segment.segment ? "SegmentTabs-tab--active" : ""}`} 26 - > 27 - {segment.title} 28 - {segment.children} 29 - </Link> 30 - ))} 31 </nav> 32 ); 33 }
··· 1 "use client"; 2 3 import { useSelectedLayoutSegment } from "next/navigation"; 4 + import Link, { useLinkStatus } from "next/link"; 5 import "./SegmentTabs.css"; 6 7 interface SegmentTabsProps { ··· 12 href?: string; 13 }>; 14 basePath?: string; 15 + } 16 + 17 + function TabContent({ 18 + title, 19 + children, 20 + isActive, 21 + }: { 22 + title: string; 23 + children?: React.ReactNode; 24 + isActive: boolean; 25 + }) { 26 + const { pending } = useLinkStatus(); 27 + const className = pending 28 + ? "SegmentTabs-tabText--pending" 29 + : isActive 30 + ? "SegmentTabs-tabText--active" 31 + : undefined; 32 + return ( 33 + <span className={className}> 34 + {title} 35 + {children} 36 + </span> 37 + ); 38 } 39 40 export function SegmentTabs({ segments, basePath = "/" }: SegmentTabsProps) { 41 const selected = useSelectedLayoutSegment(); 42 return ( 43 <nav className="SegmentTabs"> 44 + {segments.map((segment) => { 45 + const isActive = selected === segment.segment; 46 + return ( 47 + <Link 48 + key={segment.segment} 49 + href={segment.href ?? basePath + (segment.segment ?? "")} 50 + className={`SegmentTabs-tab ${isActive ? "SegmentTabs-tab--active" : ""}`} 51 + > 52 + <TabContent title={segment.title} isActive={isActive}> 53 + {segment.children} 54 + </TabContent> 55 + </Link> 56 + ); 57 + })} 58 </nav> 59 ); 60 }
+28 -79
app/TrailCard.css
··· 1 .TrailCard { 2 - display: block; 3 - position: relative; 4 - } 5 - 6 - .TrailCard-underlay { 7 - position: absolute; 8 - inset: 0; 9 - } 10 - 11 - .TrailCard-bg { 12 - border-radius: 12px; 13 - padding: 1.5rem; 14 - transition: all 0.2s ease; 15 - position: relative; 16 - pointer-events: none; 17 display: flex; 18 flex-direction: column; 19 gap: 0.75rem; 20 } 21 22 - .TrailCard-bg::before { 23 - content: ""; 24 - position: absolute; 25 - inset: 0; 26 - background-color: var(--bg-color); 27 - border-radius: 12px; 28 - border: 1.5px solid; 29 - border-color: color-mix(in srgb, var(--accent-color) 15%, rgba(0, 0, 0, 0.08)); 30 - filter: var(--user-content-filter); 31 - transition: all 0.2s ease; 32 - z-index: 0; 33 - } 34 - 35 - .TrailCard-title, 36 - .TrailCard-description, 37 - .TrailCard-meta { 38 - position: relative; 39 - z-index: 1; 40 - } 41 - 42 - .TrailCard-activity { 43 - display: flex; 44 - align-items: center; 45 - gap: 0.375rem; 46 - pointer-events: auto; 47 - } 48 - 49 - .TrailCard-walkers { 50 - display: flex; 51 - gap: 0.25rem; 52 - align-items: center; 53 - padding: 0.25rem 0.45rem; 54 - border-radius: 12px; 55 - position: relative; 56 - isolation: isolate; 57 - } 58 - 59 - .TrailCard-walkers::before { 60 - content: ""; 61 - position: absolute; 62 - inset: 0; 63 - background: var(--accent-color-transparent); 64 - border: 1px solid var(--accent-color-transparent); 65 - border-radius: 12px; 66 - filter: var(--user-content-filter); 67 - z-index: -1; 68 - } 69 - 70 - @media (hover: hover) { 71 - .TrailCard:hover .TrailCard-bg { 72 - transform: translateY(-2px); 73 - } 74 - 75 - .TrailCard:hover .TrailCard-bg::before { 76 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 77 - border-color: var(--accent-color); 78 - } 79 - } 80 - 81 - .TrailCard:active .TrailCard-bg::before { 82 - border-color: var(--accent-color); 83 - transition-duration: 0.05s; 84 - } 85 - 86 .TrailCard-title { 87 font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; 88 font-size: 1.125rem; ··· 116 .TrailCard-steps { 117 text-transform: lowercase; 118 }
··· 1 .TrailCard { 2 display: flex; 3 flex-direction: column; 4 gap: 0.75rem; 5 } 6 7 .TrailCard-title { 8 font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; 9 font-size: 1.125rem; ··· 37 .TrailCard-steps { 38 text-transform: lowercase; 39 } 40 + 41 + .TrailCard-activity { 42 + display: flex; 43 + align-items: center; 44 + gap: 0.375rem; 45 + } 46 + 47 + .TrailCard-walkers { 48 + display: flex; 49 + gap: 0.25rem; 50 + align-items: center; 51 + padding: 0.25rem 0.45rem; 52 + border-radius: 12px; 53 + position: relative; 54 + isolation: isolate; 55 + } 56 + 57 + .TrailCard-walkers::before { 58 + content: ""; 59 + position: absolute; 60 + inset: 0; 61 + background: var(--accent-color-transparent); 62 + border: 1px solid var(--accent-color-transparent); 63 + border-radius: 12px; 64 + filter: var(--user-content-filter); 65 + z-index: -1; 66 + pointer-events: none; 67 + }
+15 -47
app/TrailCard.tsx
··· 1 - import Link from "next/link"; 2 import type { User } from "../data/queries"; 3 - import { loadTrailActiveWalkers } from "../data/queries"; 4 - import { FloatingAvatar } from "./FloatingAvatar"; 5 import "./TrailCard.css"; 6 7 type Props = { 8 - uri: string; 9 rkey: string; 10 creatorHandle: string; 11 title: string; ··· 14 backgroundColor: string; 15 creator: User; 16 stopsCount: number; 17 }; 18 19 - async function ActiveWalkers({ trailUri }: { trailUri: string }) { 20 - const walkers = await loadTrailActiveWalkers(trailUri); 21 - const displayWalkers = walkers.filter((w) => w.avatar).slice(0, 3); 22 - 23 - if (displayWalkers.length === 0) return null; 24 - 25 - return ( 26 - <div className="TrailCard-walkers"> 27 - {displayWalkers.map((walker, i) => { 28 - return ( 29 - <FloatingAvatar 30 - key={i} 31 - src={walker.avatar} 32 - title={walker.handle} 33 - contained={true} 34 - opaque={true} 35 - handle={walker.handle} 36 - /> 37 - ); 38 - })} 39 - </div> 40 - ); 41 - } 42 - 43 - export async function TrailCard({ 44 - uri, 45 rkey, 46 creatorHandle, 47 title, ··· 50 backgroundColor, 51 creator, 52 stopsCount, 53 }: Props) { 54 return ( 55 - <div className="TrailCard"> 56 - <Link href={`/@${creatorHandle}/trail/${rkey}`} className="TrailCard-underlay" /> 57 - <div 58 - className="TrailCard-bg" 59 - style={ 60 - { 61 - "--accent-color": accentColor, 62 - "--accent-color-transparent": `${accentColor}15`, 63 - "--bg-color": backgroundColor, 64 - } as React.CSSProperties 65 - } 66 - > 67 - <h3 className="TrailCard-title"> 68 - <span>{title}</span> 69 - </h3> 70 <p className="TrailCard-description">{description}</p> 71 <div className="TrailCard-meta"> 72 <span className="TrailCard-creator">@{creator.handle}</span> 73 <div className="TrailCard-activity"> 74 - <ActiveWalkers trailUri={uri} /> 75 <span className="TrailCard-steps">{stopsCount} stops</span> 76 </div> 77 </div> 78 </div> 79 - </div> 80 ); 81 }
··· 1 import type { User } from "../data/queries"; 2 + import type { ReactNode } from "react"; 3 + import { Card } from "@/components/Card"; 4 import "./TrailCard.css"; 5 6 type Props = { 7 + uri?: string; 8 rkey: string; 9 creatorHandle: string; 10 title: string; ··· 13 backgroundColor: string; 14 creator: User; 15 stopsCount: number; 16 + walkersSlot?: ReactNode; 17 }; 18 19 + export function TrailCard({ 20 rkey, 21 creatorHandle, 22 title, ··· 25 backgroundColor, 26 creator, 27 stopsCount, 28 + walkersSlot, 29 }: Props) { 30 return ( 31 + <Card 32 + href={`/@${creatorHandle}/trail/${rkey}`} 33 + accentColor={accentColor} 34 + backgroundColor={backgroundColor} 35 + > 36 + <div className="TrailCard"> 37 + <h3 className="TrailCard-title">{title}</h3> 38 <p className="TrailCard-description">{description}</p> 39 <div className="TrailCard-meta"> 40 <span className="TrailCard-creator">@{creator.handle}</span> 41 <div className="TrailCard-activity"> 42 + {walkersSlot} 43 <span className="TrailCard-steps">{stopsCount} stops</span> 44 </div> 45 </div> 46 </div> 47 + </Card> 48 ); 49 }
+27
app/TrailCardWalkers.tsx
···
··· 1 + import { loadTrailActiveWalkers } from "../data/queries"; 2 + import { FloatingAvatar } from "./FloatingAvatar"; 3 + 4 + export async function TrailCardWalkers({ trailUri }: { trailUri: string }) { 5 + const walkers = await loadTrailActiveWalkers(trailUri); 6 + const displayWalkers = walkers.filter((w) => w.avatar).slice(0, 3); 7 + 8 + if (displayWalkers.length === 0) return null; 9 + 10 + return ( 11 + <div className="TrailCard-walkers"> 12 + {displayWalkers.map((walker, i) => { 13 + return ( 14 + <FloatingAvatar 15 + key={i} 16 + src={walker.avatar} 17 + title={walker.handle} 18 + contained={true} 19 + opaque={true} 20 + handle={walker.handle} 21 + noLink 22 + /> 23 + ); 24 + })} 25 + </div> 26 + ); 27 + }
+2 -1
app/TrailsList.tsx
··· 1 import type { TrailCardData } from "../data/queries"; 2 import { TrailCard } from "./TrailCard"; 3 import "./TrailsList.css"; 4 5 type Props = { ··· 12 {trails.map((trail) => ( 13 <TrailCard 14 key={trail.uri} 15 - uri={trail.uri} 16 rkey={trail.rkey} 17 creatorHandle={trail.creatorHandle} 18 title={trail.title} ··· 21 backgroundColor={trail.backgroundColor} 22 creator={trail.creator} 23 stopsCount={trail.stopsCount} 24 /> 25 ))} 26 </div>
··· 1 import type { TrailCardData } from "../data/queries"; 2 import { TrailCard } from "./TrailCard"; 3 + import { TrailCardWalkers } from "./TrailCardWalkers"; 4 import "./TrailsList.css"; 5 6 type Props = { ··· 13 {trails.map((trail) => ( 14 <TrailCard 15 key={trail.uri} 16 rkey={trail.rkey} 17 creatorHandle={trail.creatorHandle} 18 title={trail.title} ··· 21 backgroundColor={trail.backgroundColor} 22 creator={trail.creator} 23 stopsCount={trail.stopsCount} 24 + walkersSlot={<TrailCardWalkers trailUri={trail.uri} />} 25 /> 26 ))} 27 </div>
+34
app/at/(trail)/[handle]/trail/[rkey]/AccentButton.css
··· 52 cursor: not-allowed; 53 } 54 55 /* Mobile responsive */ 56 @media (max-width: 768px) { 57 .AccentButton--large {
··· 52 cursor: not-allowed; 53 } 54 55 + /* Pending state - "engaged" not "disabled" */ 56 + .AccentButton--pending:disabled { 57 + opacity: 0.85; 58 + cursor: pointer; 59 + overflow: hidden; 60 + transform: scale(1); 61 + } 62 + 63 + /* Shimmer sweep across button */ 64 + .AccentButton--pending::after { 65 + content: ""; 66 + position: absolute; 67 + inset: 0; 68 + pointer-events: none; 69 + background: linear-gradient( 70 + 90deg, 71 + transparent 0%, 72 + rgba(255, 255, 255, 0.15) 50%, 73 + transparent 100% 74 + ); 75 + transform: translateX(-100%); 76 + animation: accent-shimmer 1.2s infinite; 77 + animation-delay: 150ms; 78 + } 79 + 80 + @keyframes accent-shimmer { 81 + 0% { 82 + transform: translateX(-100%); 83 + } 84 + 100% { 85 + transform: translateX(100%); 86 + } 87 + } 88 + 89 /* Mobile responsive */ 90 @media (max-width: 768px) { 91 .AccentButton--large {
+27 -5
app/at/(trail)/[handle]/trail/[rkey]/AccentButton.tsx
··· 1 import "./AccentButton.css"; 2 3 type Props = { 4 children: React.ReactNode; 5 - onClick?: () => void; 6 disabled?: boolean; 7 type?: "button" | "submit"; 8 size?: "medium" | "large"; ··· 10 11 export function AccentButton({ 12 children, 13 - onClick, 14 disabled = false, 15 type = "button", 16 size = "large", 17 }: Props) { 18 - const className = `AccentButton AccentButton--${size}`; 19 20 return ( 21 - <button type={type} onClick={onClick} disabled={disabled} className={className}> 22 - {children} 23 </button> 24 ); 25 }
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 import "./AccentButton.css"; 5 6 type Props = { 7 children: React.ReactNode; 8 + action?: () => Promise<void> | void; 9 + pendingChildren?: React.ReactNode; 10 disabled?: boolean; 11 type?: "button" | "submit"; 12 size?: "medium" | "large"; ··· 14 15 export function AccentButton({ 16 children, 17 + action, 18 + pendingChildren, 19 disabled = false, 20 type = "button", 21 size = "large", 22 }: Props) { 23 + const [isPending, startTransition] = useTransition(); 24 + 25 + const handleClick = (e: React.MouseEvent) => { 26 + if (!action || isPending) return; 27 + e.stopPropagation(); 28 + startTransition(async () => { 29 + await action(); 30 + }); 31 + }; 32 + 33 + const classNames = ["AccentButton", `AccentButton--${size}`, isPending && "AccentButton--pending"] 34 + .filter(Boolean) 35 + .join(" "); 36 37 return ( 38 + <button 39 + type={type} 40 + onClick={handleClick} 41 + disabled={disabled || isPending} 42 + className={classNames} 43 + > 44 + {isPending && pendingChildren ? pendingChildren : children} 45 </button> 46 ); 47 }
+1 -1
app/at/(trail)/[handle]/trail/[rkey]/TrailCompletionCard.tsx
··· 11 <div className="TrailCompletionCard"> 12 <h2 className="TrailCompletionCard-title">you walked this trail</h2> 13 <p className="TrailCompletionCard-text">you walked the whole thing. share it if you want.</p> 14 - <AccentButton onClick={onWriteReflection} size="medium"> 15 write a reflection 16 </AccentButton> 17 <div className="TrailCompletionCard-nav">
··· 11 <div className="TrailCompletionCard"> 12 <h2 className="TrailCompletionCard-title">you walked this trail</h2> 13 <p className="TrailCompletionCard-text">you walked the whole thing. share it if you want.</p> 14 + <AccentButton action={onWriteReflection} size="medium"> 15 write a reflection 16 </AccentButton> 17 <div className="TrailCompletionCard-nav">
-19
app/at/(trail)/[handle]/trail/[rkey]/TrailOverview.css
··· 111 } 112 113 .TrailOverview-abandonButton { 114 - font-family: inherit; 115 font-size: 0.875rem; 116 - padding: 0.75rem 1rem; 117 - background: transparent; 118 - color: var(--text-muted); 119 - border: none; 120 - cursor: pointer; 121 - transition: all 0.2s ease; 122 - text-transform: lowercase; 123 - } 124 - 125 - @media (hover: hover) { 126 - .TrailOverview-abandonButton:hover { 127 - color: var(--text-secondary); 128 - } 129 - } 130 - 131 - .TrailOverview-abandonButton:active { 132 - color: var(--text-secondary); 133 - transition-duration: 0.05s; 134 } 135 136 /* Edit mode styles */
··· 111 } 112 113 .TrailOverview-abandonButton { 114 font-size: 0.875rem; 115 } 116 117 /* Edit mode styles */
+26 -21
app/at/(trail)/[handle]/trail/[rkey]/TrailOverview.tsx
··· 1 import { useRouter } from "next/navigation"; 2 import { BackButton } from "@/app/BackButton"; 3 import { UserBadge } from "@/app/UserBadge"; 4 import { TrailOverviewStop } from "./TrailOverviewStop"; 5 import { TrailRegisterDeferred } from "./TrailRegisterDeferred"; 6 import { AccentButton } from "./AccentButton"; 7 import { startWalk, abandonWalk, forgetTrail, deleteTrail } from "@/data/actions"; 8 import { AddButton } from "@/app/EditButtons"; 9 import { useEditMode } from "./EditModeContext"; 10 import { useAuthAction } from "@/auth/useAuthAction"; 11 import "./TrailOverview.css"; 12 - import type { TrailDetailData, TrailStop } from "@/data/queries"; 13 - import { Suspense } from "react"; 14 15 type Props = { 16 trail: TrailDetailData; 17 onModeChange: (mode: "walk") => void; 18 canEdit: boolean; 19 - onDelete?: () => void; 20 - onPublish?: () => void; 21 publishError?: string[] | null; 22 - isPublishing?: boolean; 23 }; 24 25 export function TrailOverview({ ··· 29 onDelete, 30 onPublish, 31 publishError, 32 - isPublishing, 33 }: Props) { 34 const router = useRouter(); 35 const requireAuth = useAuthAction(); ··· 51 const lastStop = trail.stops[trail.stops.length - 1]; 52 const shouldShowAddButton = lastStop?.title?.trim() && trail.stops.length < 12; 53 54 - const handleStartWalk = async () => { 55 requireAuth(); 56 if (!trail.yourWalk && !isEditing) { 57 await startWalk(trail.header.uri, trail.header.cid); ··· 59 onModeChange("walk"); 60 }; 61 62 - const handleAbandon = async () => { 63 if (confirm("abandon this trail? your progress will be lost")) { 64 if (trail.yourWalk) { 65 await abandonWalk(trail.yourWalk!.uri); ··· 67 } 68 }; 69 70 - const handleDeleteTrail = async () => { 71 if (confirm("delete this trail? it will be gone for everyone forever")) { 72 await deleteTrail(trail.header.uri); 73 router.push("/"); ··· 271 272 <div className="TrailOverview-actions"> 273 <AccentButton 274 - onClick={handleStartWalk} 275 size="large" 276 disabled={isEditing && !canStartWalking} 277 > ··· 282 : "walk this trail"} 283 </AccentButton> 284 {isEditing && onPublish && ( 285 - <AccentButton onClick={onPublish} size="medium" disabled={isPublishing}> 286 - {isPublishing ? "publishing..." : "publish trail"} 287 </AccentButton> 288 )} 289 {isEditing && onDelete && ( 290 - <button onClick={onDelete} className="TrailOverview-abandonButton"> 291 - delete draft 292 - </button> 293 )} 294 {!isEditing && trail.yourWalk && ( 295 - <button onClick={handleAbandon} className="TrailOverview-abandonButton"> 296 - abandon 297 - </button> 298 )} 299 {!isEditing && !trail.yourWalk && canEdit && ( 300 - <button onClick={handleDeleteTrail} className="TrailOverview-abandonButton"> 301 - delete trail 302 - </button> 303 )} 304 </div> 305
··· 1 import { useRouter } from "next/navigation"; 2 + import { Suspense } from "react"; 3 import { BackButton } from "@/app/BackButton"; 4 import { UserBadge } from "@/app/UserBadge"; 5 import { TrailOverviewStop } from "./TrailOverviewStop"; 6 import { TrailRegisterDeferred } from "./TrailRegisterDeferred"; 7 import { AccentButton } from "./AccentButton"; 8 + import { TextButton } from "@/components/TextButton"; 9 import { startWalk, abandonWalk, forgetTrail, deleteTrail } from "@/data/actions"; 10 import { AddButton } from "@/app/EditButtons"; 11 import { useEditMode } from "./EditModeContext"; 12 import { useAuthAction } from "@/auth/useAuthAction"; 13 + import type { TrailDetailData, TrailStop } from "@/data/queries"; 14 import "./TrailOverview.css"; 15 16 type Props = { 17 trail: TrailDetailData; 18 onModeChange: (mode: "walk") => void; 19 canEdit: boolean; 20 + onDelete?: () => Promise<void> | void; 21 + onPublish?: () => Promise<void> | void; 22 publishError?: string[] | null; 23 }; 24 25 export function TrailOverview({ ··· 29 onDelete, 30 onPublish, 31 publishError, 32 }: Props) { 33 const router = useRouter(); 34 const requireAuth = useAuthAction(); ··· 50 const lastStop = trail.stops[trail.stops.length - 1]; 51 const shouldShowAddButton = lastStop?.title?.trim() && trail.stops.length < 12; 52 53 + const startWalkAction = async () => { 54 requireAuth(); 55 if (!trail.yourWalk && !isEditing) { 56 await startWalk(trail.header.uri, trail.header.cid); ··· 58 onModeChange("walk"); 59 }; 60 61 + const abandonAction = async () => { 62 if (confirm("abandon this trail? your progress will be lost")) { 63 if (trail.yourWalk) { 64 await abandonWalk(trail.yourWalk!.uri); ··· 66 } 67 }; 68 69 + const deleteTrailAction = async () => { 70 if (confirm("delete this trail? it will be gone for everyone forever")) { 71 await deleteTrail(trail.header.uri); 72 router.push("/"); ··· 270 271 <div className="TrailOverview-actions"> 272 <AccentButton 273 + action={startWalkAction} 274 size="large" 275 disabled={isEditing && !canStartWalking} 276 > ··· 281 : "walk this trail"} 282 </AccentButton> 283 {isEditing && onPublish && ( 284 + <AccentButton action={onPublish} size="medium" pendingChildren="publishing..."> 285 + publish trail 286 </AccentButton> 287 )} 288 {isEditing && onDelete && ( 289 + <span className="TrailOverview-abandonButton"> 290 + <TextButton action={onDelete} pendingChildren="deleting..."> 291 + delete draft 292 + </TextButton> 293 + </span> 294 )} 295 {!isEditing && trail.yourWalk && ( 296 + <span className="TrailOverview-abandonButton"> 297 + <TextButton action={abandonAction} pendingChildren="abandoning..."> 298 + abandon 299 + </TextButton> 300 + </span> 301 )} 302 {!isEditing && !trail.yourWalk && canEdit && ( 303 + <span className="TrailOverview-abandonButton"> 304 + <TextButton action={deleteTrailAction} pendingChildren="deleting..."> 305 + delete trail 306 + </TextButton> 307 + </span> 308 )} 309 </div> 310
-23
app/at/(trail)/[handle]/trail/[rkey]/TrailProgress.css
··· 182 } 183 184 .TrailProgress-statusLeave { 185 - font-family: inherit; 186 font-size: 0.6875rem; 187 - line-height: 1; 188 - padding: 0.5rem 0; 189 - background: none; 190 - border: none; 191 - color: var(--text-muted); 192 - cursor: pointer; 193 - text-transform: lowercase; 194 - transition: all 0.2s ease; 195 - white-space: nowrap; 196 - } 197 - 198 - @media (hover: hover) { 199 - .TrailProgress-statusLeave:hover { 200 - color: var(--accent-color); 201 - text-decoration: underline; 202 - filter: var(--user-content-filter); 203 - } 204 - } 205 - 206 - .TrailProgress-statusLeave:active { 207 - color: var(--accent-color); 208 - transition-duration: 0.05s; 209 } 210 211 /* Tablet and desktop enhancements */
··· 182 } 183 184 .TrailProgress-statusLeave { 185 font-size: 0.6875rem; 186 } 187 188 /* Tablet and desktop enhancements */
+7 -4
app/at/(trail)/[handle]/trail/[rkey]/TrailProgress.tsx
··· 1 import Link from "next/link"; 2 import "./TrailProgress.css"; 3 4 type Props = { ··· 7 furthestStep?: number; 8 onStepClick?: (index: number) => void; 9 isWalking?: boolean; 10 - onLeaveTrail?: () => void; 11 backLink?: { to: string; text: string }; 12 }; 13 ··· 73 })} 74 </div> 75 {showRightButton ? ( 76 - <button onClick={onLeaveTrail} className="TrailProgress-statusLeave"> 77 - abandon 78 - </button> 79 ) : ( 80 <span className="TrailProgress-spacer">abandon</span> 81 )}
··· 1 import Link from "next/link"; 2 + import { TextButton } from "@/components/TextButton"; 3 import "./TrailProgress.css"; 4 5 type Props = { ··· 8 furthestStep?: number; 9 onStepClick?: (index: number) => void; 10 isWalking?: boolean; 11 + onLeaveTrail?: () => Promise<void> | void; 12 backLink?: { to: string; text: string }; 13 }; 14 ··· 74 })} 75 </div> 76 {showRightButton ? ( 77 + <span className="TrailProgress-statusLeave"> 78 + <TextButton action={onLeaveTrail!} pendingChildren="abandoning..."> 79 + abandon 80 + </TextButton> 81 + </span> 82 ) : ( 83 <span className="TrailProgress-spacer">abandon</span> 84 )}
+1 -6
app/at/(trail)/[handle]/trail/[rkey]/TrailStop.tsx
··· 92 {/* View mode: done button */} 93 {isCurrent && !isEditing && ( 94 <div className="TrailStop-actions"> 95 - <AccentButton 96 - onClick={() => { 97 - onContinue(); 98 - }} 99 - size="large" 100 - > 101 {stop.buttonText || "done that"} 102 </AccentButton> 103 </div>
··· 92 {/* View mode: done button */} 93 {isCurrent && !isEditing && ( 94 <div className="TrailStop-actions"> 95 + <AccentButton action={onContinue} size="large"> 96 {stop.buttonText || "done that"} 97 </AccentButton> 98 </div>
-20
app/at/(trail)/[handle]/trail/[rkey]/TrailWalk.css
··· 143 } 144 145 .TrailWalk-abandonButton { 146 - font-family: inherit; 147 font-size: 0.875rem; 148 - padding: 0.75rem 1rem; 149 - background-color: transparent; 150 - color: var(--text-muted); 151 - border: none; 152 - cursor: pointer; 153 - transition: all 0.2s ease; 154 - text-transform: lowercase; 155 - } 156 - 157 - @media (hover: hover) { 158 - .TrailWalk-abandonButton:hover { 159 - color: var(--text-secondary); 160 - text-decoration: underline; 161 - } 162 - } 163 - 164 - .TrailWalk-abandonButton:active { 165 - color: var(--text-secondary); 166 - transition-duration: 0.05s; 167 } 168 169 .TrailWalk-publishButton {
··· 143 } 144 145 .TrailWalk-abandonButton { 146 font-size: 0.875rem; 147 } 148 149 .TrailWalk-publishButton {
+26 -15
app/at/(trail)/[handle]/trail/[rkey]/TrailWalk.tsx
··· 1 - import { useState, useLayoutEffect, useRef, Suspense, Activity, type ReactNode } from "react"; 2 import type { TrailDetailData } from "@/data/queries"; 3 import { BackButton } from "@/app/BackButton"; 4 import { TrailProgress } from "./TrailProgress"; ··· 7 import { TrailCompletionCard } from "./TrailCompletionCard"; 8 import { TrailRegisterDeferred } from "./TrailRegisterDeferred"; 9 import { TrailWalkersOverlay } from "./TrailWalkersOverlay"; 10 import { visitStop, completeTrail, abandonWalk, deleteCompletion } from "@/data/actions"; 11 import { useAuthAction } from "@/auth/useAuthAction"; 12 import type { EmbedCache } from "./StopEmbed"; 13 import "./TrailWalk.css"; 14 - 15 - import { EmbedCacheContext } from "./StopEmbed"; 16 17 function RevealedStop({ 18 revealed, ··· 37 rkey: string; 38 onModeChange: (mode: "overview") => void; 39 isEditMode?: boolean; 40 - onPublish?: () => void; 41 publishError?: string[] | null; 42 - isPublishing?: boolean; 43 initialEmbeds?: Array<[string, Promise<React.ReactElement>]>; 44 }; 45 ··· 49 isEditMode, 50 onPublish, 51 publishError, 52 - isPublishing, 53 initialEmbeds, 54 }: Props) { 55 const { header, stops, yourWalk } = trail; ··· 195 window.open(`https://bsky.app/intent/compose?text=${text}`, "_blank"); 196 }; 197 198 - const handleAbandon = async () => { 199 if (confirm("abandon this trail? your progress will be lost")) { 200 if (yourWalk) { 201 await abandonWalk(yourWalk.uri); 202 - onModeChange("overview"); 203 } 204 } 205 }; ··· 228 furthestStep={isEditMode ? stops.length - 1 : furthestStopIndex} 229 onStepClick={handleGoToStop} 230 isWalking={!isCompleted} 231 - onLeaveTrail={isEditMode ? undefined : handleAbandon} 232 backLink={isEditMode ? { to: "/drafts", text: "← drafts" } : undefined} 233 /> 234 <div className="TrailWalk-progressLine" /> ··· 342 343 {!isCompleted && !isEditMode && ( 344 <div className="TrailWalk-footer"> 345 - <button onClick={handleAbandon} className="TrailWalk-abandonButton"> 346 - abandon 347 - </button> 348 </div> 349 )} 350 ··· 359 </ul> 360 </div> 361 )} 362 - <button onClick={onPublish} disabled={isPublishing} className="TrailWalk-publishButton"> 363 - {isPublishing ? "publishing..." : "publish trail"} 364 - </button> 365 </div> 366 )} 367 </div>
··· 1 + import { 2 + startTransition, 3 + useState, 4 + useLayoutEffect, 5 + useRef, 6 + Suspense, 7 + Activity, 8 + type ReactNode, 9 + } from "react"; 10 import type { TrailDetailData } from "@/data/queries"; 11 import { BackButton } from "@/app/BackButton"; 12 import { TrailProgress } from "./TrailProgress"; ··· 15 import { TrailCompletionCard } from "./TrailCompletionCard"; 16 import { TrailRegisterDeferred } from "./TrailRegisterDeferred"; 17 import { TrailWalkersOverlay } from "./TrailWalkersOverlay"; 18 + import { AccentButton } from "./AccentButton"; 19 + import { TextButton } from "@/components/TextButton"; 20 import { visitStop, completeTrail, abandonWalk, deleteCompletion } from "@/data/actions"; 21 import { useAuthAction } from "@/auth/useAuthAction"; 22 import type { EmbedCache } from "./StopEmbed"; 23 + import { EmbedCacheContext } from "./StopEmbed"; 24 import "./TrailWalk.css"; 25 26 function RevealedStop({ 27 revealed, ··· 46 rkey: string; 47 onModeChange: (mode: "overview") => void; 48 isEditMode?: boolean; 49 + onPublish?: () => Promise<void> | void; 50 publishError?: string[] | null; 51 initialEmbeds?: Array<[string, Promise<React.ReactElement>]>; 52 }; 53 ··· 57 isEditMode, 58 onPublish, 59 publishError, 60 initialEmbeds, 61 }: Props) { 62 const { header, stops, yourWalk } = trail; ··· 202 window.open(`https://bsky.app/intent/compose?text=${text}`, "_blank"); 203 }; 204 205 + const abandonAction = async () => { 206 if (confirm("abandon this trail? your progress will be lost")) { 207 if (yourWalk) { 208 await abandonWalk(yourWalk.uri); 209 + startTransition(() => { 210 + onModeChange("overview"); 211 + }); 212 } 213 } 214 }; ··· 237 furthestStep={isEditMode ? stops.length - 1 : furthestStopIndex} 238 onStepClick={handleGoToStop} 239 isWalking={!isCompleted} 240 + onLeaveTrail={isEditMode ? undefined : abandonAction} 241 backLink={isEditMode ? { to: "/drafts", text: "← drafts" } : undefined} 242 /> 243 <div className="TrailWalk-progressLine" /> ··· 351 352 {!isCompleted && !isEditMode && ( 353 <div className="TrailWalk-footer"> 354 + <span className="TrailWalk-abandonButton"> 355 + <TextButton action={abandonAction} pendingChildren="abandoning..."> 356 + abandon 357 + </TextButton> 358 + </span> 359 </div> 360 )} 361 ··· 370 </ul> 371 </div> 372 )} 373 + <AccentButton action={onPublish!} size="medium" pendingChildren="publishing..."> 374 + publish trail 375 + </AccentButton> 376 </div> 377 )} 378 </div>
+2
app/at/(trail)/[handle]/trail/[rkey]/page.tsx
··· 39 loadCurrentUser(), 40 ]); 41 42 // Preload embeds for all stops that have external links 43 const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = trail.stops 44 .filter((stop) => stop.external?.uri)
··· 39 loadCurrentUser(), 40 ]); 41 42 + // await new Promise(resolve => setTimeout(resolve, 4000)) 43 + 44 // Preload embeds for all stops that have external links 45 const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = trail.stops 46 .filter((stop) => stop.external?.uri)
+5 -4
app/at/[handle]/completed/page.tsx
··· 1 import { loadUserCompletedTrails } from "@/data/queries"; 2 - import { TrailCard } from "../../../TrailCard"; 3 - import { EmptyState } from "../../../EmptyState"; 4 - import "../../../TrailsList.css"; 5 6 export default async function ProfileCompletedPage({ 7 params, ··· 21 {completedTrails.map((trail) => ( 22 <TrailCard 23 key={trail.rkey} 24 - uri={trail.uri} 25 rkey={trail.rkey} 26 creatorHandle={trail.creator.handle} 27 title={trail.title} ··· 30 backgroundColor={trail.backgroundColor} 31 creator={trail.creator} 32 stopsCount={trail.stopsCount} 33 /> 34 ))} 35 </div>
··· 1 import { loadUserCompletedTrails } from "@/data/queries"; 2 + import { TrailCard } from "@/app/TrailCard"; 3 + import { TrailCardWalkers } from "@/app/TrailCardWalkers"; 4 + import { EmptyState } from "@/app/EmptyState"; 5 + import "@/app/TrailsList.css"; 6 7 export default async function ProfileCompletedPage({ 8 params, ··· 22 {completedTrails.map((trail) => ( 23 <TrailCard 24 key={trail.rkey} 25 rkey={trail.rkey} 26 creatorHandle={trail.creator.handle} 27 title={trail.title} ··· 30 backgroundColor={trail.backgroundColor} 31 creator={trail.creator} 32 stopsCount={trail.stopsCount} 33 + walkersSlot={<TrailCardWalkers trailUri={trail.uri} />} 34 /> 35 ))} 36 </div>
+2 -7
app/drafts/[rkey]/DraftEditor.tsx
··· 198 window.scrollTo(0, 0); 199 }; 200 201 - const [isPublishing, setIsPublishing] = useState(false); 202 const [publishError, setPublishError] = useState<string[] | null>(null); 203 const [inlineErrors, setInlineErrors] = useState<Record<string, string>>({}); 204 205 - const handlePublish = async () => { 206 requireAuth(); 207 setPublishError(null); 208 setInlineErrors({}); 209 - setIsPublishing(true); 210 211 try { 212 await saver.saveNow(localDraft); ··· 222 if (!result.success) { 223 setPublishError(result.errors); 224 setInlineErrors(result.inlineErrors); 225 - setIsPublishing(false); 226 return; 227 } 228 ··· 231 } catch (error: unknown) { 232 const message = error instanceof Error ? error.message : "something went wrong"; 233 setPublishError([message]); 234 - setIsPublishing(false); 235 } 236 }; 237 ··· 329 rkey={rkey} 330 onModeChange={() => setStage("overview")} 331 isEditMode={true} 332 - onPublish={handlePublish} 333 publishError={publishError} 334 - isPublishing={isPublishing} 335 initialEmbeds={initialEmbeds} 336 /> 337 )}
··· 198 window.scrollTo(0, 0); 199 }; 200 201 const [publishError, setPublishError] = useState<string[] | null>(null); 202 const [inlineErrors, setInlineErrors] = useState<Record<string, string>>({}); 203 204 + const publishAction = async () => { 205 requireAuth(); 206 setPublishError(null); 207 setInlineErrors({}); 208 209 try { 210 await saver.saveNow(localDraft); ··· 220 if (!result.success) { 221 setPublishError(result.errors); 222 setInlineErrors(result.inlineErrors); 223 return; 224 } 225 ··· 228 } catch (error: unknown) { 229 const message = error instanceof Error ? error.message : "something went wrong"; 230 setPublishError([message]); 231 } 232 }; 233 ··· 325 rkey={rkey} 326 onModeChange={() => setStage("overview")} 327 isEditMode={true} 328 + onPublish={publishAction} 329 publishError={publishError} 330 initialEmbeds={initialEmbeds} 331 /> 332 )}
+46
components/ActionButton.tsx
···
··· 1 + "use client"; 2 + 3 + import { useTransition, type ReactNode } from "react"; 4 + 5 + type Props = { 6 + action: () => Promise<void> | void; 7 + children: ReactNode; 8 + pendingChildren?: ReactNode; 9 + className?: string; 10 + pendingClassName?: string; 11 + disabled?: boolean; 12 + type?: "button" | "submit"; 13 + }; 14 + 15 + export function ActionButton({ 16 + action, 17 + children, 18 + pendingChildren, 19 + className, 20 + pendingClassName, 21 + disabled = false, 22 + type = "button", 23 + }: Props) { 24 + const [isPending, startTransition] = useTransition(); 25 + 26 + const handleClick = (e: React.MouseEvent) => { 27 + if (isPending) return; 28 + e.stopPropagation(); 29 + startTransition(async () => { 30 + await action(); 31 + }); 32 + }; 33 + 34 + const classNames = [className, isPending && pendingClassName].filter(Boolean).join(" "); 35 + 36 + return ( 37 + <button 38 + type={type} 39 + onClick={handleClick} 40 + disabled={disabled || isPending} 41 + className={classNames} 42 + > 43 + {isPending && pendingChildren ? pendingChildren : children} 44 + </button> 45 + ); 46 + }
+75
components/Card.css
···
··· 1 + .Card { 2 + display: block; 3 + text-decoration: none; 4 + color: inherit; 5 + } 6 + 7 + .Card-bg { 8 + border-radius: 12px; 9 + padding: 1.5rem; 10 + position: relative; 11 + transition: transform 0.2s ease; 12 + isolation: isolate; 13 + will-change: transform; 14 + } 15 + 16 + .Card-bg::before { 17 + content: ""; 18 + position: absolute; 19 + inset: 0; 20 + border-radius: 12px; 21 + background-color: var(--bg-color); 22 + border: 1.5px solid color-mix(in srgb, var(--accent-color) 15%, rgba(0, 0, 0, 0.08)); 23 + filter: var(--user-content-filter); 24 + z-index: -2; 25 + pointer-events: none; 26 + } 27 + 28 + .Card-bg::after { 29 + content: ""; 30 + position: absolute; 31 + inset: 0; 32 + border-radius: 12px; 33 + border: 1.5px solid var(--accent-color); 34 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 35 + filter: var(--user-content-filter); 36 + opacity: 0; 37 + transition: opacity 0.2s ease; 38 + will-change: opacity; 39 + z-index: -1; 40 + pointer-events: none; 41 + } 42 + 43 + @media (hover: hover) { 44 + .Card:hover .Card-bg { 45 + transform: translateY(-2px); 46 + } 47 + 48 + .Card:hover .Card-bg::after { 49 + opacity: 1; 50 + } 51 + 52 + .Card-bg--pending { 53 + transform: translateY(-2px); 54 + } 55 + } 56 + 57 + .Card:active .Card-bg { 58 + transform: scale(0.99); 59 + } 60 + 61 + .Card-bg--pending::after { 62 + opacity: 1; 63 + animation: card-glow 1.5s ease-in-out infinite; 64 + animation-delay: 400ms; 65 + } 66 + 67 + @keyframes card-glow { 68 + 0%, 69 + 100% { 70 + opacity: 1; 71 + } 72 + 50% { 73 + opacity: 0.5; 74 + } 75 + }
+41
components/Card.tsx
···
··· 1 + "use client"; 2 + 3 + import Link, { useLinkStatus } from "next/link"; 4 + import type { ReactNode } from "react"; 5 + import "./Card.css"; 6 + 7 + type Props = { 8 + href: string; 9 + accentColor: string; 10 + backgroundColor: string; 11 + children: ReactNode; 12 + }; 13 + 14 + function CardBg({ accentColor, backgroundColor, children }: Omit<Props, "href">) { 15 + const { pending } = useLinkStatus(); 16 + 17 + return ( 18 + <div 19 + className={`Card-bg${pending ? " Card-bg--pending" : ""}`} 20 + style={ 21 + { 22 + "--accent-color": accentColor, 23 + "--accent-color-transparent": `${accentColor}20`, 24 + "--bg-color": backgroundColor, 25 + } as React.CSSProperties 26 + } 27 + > 28 + {children} 29 + </div> 30 + ); 31 + } 32 + 33 + export function Card({ href, accentColor, backgroundColor, children }: Props) { 34 + return ( 35 + <Link href={href} className="Card"> 36 + <CardBg accentColor={accentColor} backgroundColor={backgroundColor}> 37 + {children} 38 + </CardBg> 39 + </Link> 40 + ); 41 + }
+82
components/TextButton.css
···
··· 1 + .TextButton { 2 + font-family: inherit; 3 + font-size: inherit; 4 + line-height: 1; 5 + padding: 0.5rem 0; 6 + background: none; 7 + border: none; 8 + color: var(--text-muted); 9 + cursor: pointer; 10 + text-transform: lowercase; 11 + transition: color 0.2s ease; 12 + white-space: nowrap; 13 + } 14 + 15 + @media (hover: hover) { 16 + .TextButton:hover:not(:disabled) { 17 + color: var(--text-secondary); 18 + } 19 + } 20 + 21 + .TextButton:active:not(:disabled) { 22 + color: var(--text-secondary); 23 + transition-duration: 0.05s; 24 + } 25 + 26 + .TextButton:disabled { 27 + pointer-events: none; 28 + cursor: default; 29 + } 30 + 31 + .TextButton--pending { 32 + color: transparent; 33 + -webkit-text-fill-color: transparent; 34 + background-image: linear-gradient( 35 + to right, 36 + var(--text-muted) 0%, 37 + var(--text-secondary) 50%, 38 + var(--text-muted) 100% 39 + ); 40 + background-size: 30px 100%; 41 + background-repeat: no-repeat; 42 + background-color: var(--text-muted); 43 + background-clip: text; 44 + -webkit-background-clip: text; 45 + animation: text-shimmer 2s ease-out infinite; 46 + } 47 + 48 + @keyframes text-shimmer { 49 + 0% { 50 + background-image: linear-gradient( 51 + to right, 52 + var(--text-muted) 0%, 53 + var(--text-secondary) 50%, 54 + var(--text-muted) 100% 55 + ); 56 + background-position: -30px 0; 57 + background-clip: text; 58 + -webkit-background-clip: text; 59 + } 60 + 80% { 61 + background-image: linear-gradient( 62 + to right, 63 + var(--text-muted) 0%, 64 + var(--text-secondary) 50%, 65 + var(--text-muted) 100% 66 + ); 67 + background-position: 100px 0; 68 + background-clip: text; 69 + -webkit-background-clip: text; 70 + } 71 + 100% { 72 + background-image: linear-gradient( 73 + to right, 74 + var(--text-muted) 0%, 75 + var(--text-secondary) 50%, 76 + var(--text-muted) 100% 77 + ); 78 + background-position: 150px 0; 79 + background-clip: text; 80 + -webkit-background-clip: text; 81 + } 82 + }
+30
components/TextButton.tsx
···
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import "./TextButton.css"; 5 + 6 + type Props = { 7 + action: () => Promise<void> | void; 8 + children: string; 9 + pendingChildren: string; 10 + }; 11 + 12 + export function TextButton({ action, children, pendingChildren }: Props) { 13 + const [isPending, startTransition] = useTransition(); 14 + 15 + const handleClick = (e: React.MouseEvent) => { 16 + if (isPending) return; 17 + e.stopPropagation(); 18 + startTransition(async () => { 19 + await action(); 20 + }); 21 + }; 22 + 23 + return ( 24 + <button type="button" onClick={handleClick} disabled={isPending} className="TextButton"> 25 + <span className={isPending ? "TextButton--pending" : undefined}> 26 + {isPending ? pendingChildren : children} 27 + </span> 28 + </button> 29 + ); 30 + }
+5
data/drafts/actions.ts
··· 2 3 import "server-only"; 4 import { getDb, drafts, type DraftRecord } from "@/data/db"; 5 import { eq, and, sql } from "drizzle-orm"; 6 import { getCurrentDid } from "@/auth"; 7 import { generateTid } from "../tid"; ··· 102 version: 1, 103 }); 104 105 return rkey; 106 } 107 ··· 162 }) 163 .returning({ version: drafts.version }); 164 165 return warning 166 ? { success: true, version: result[0].version, warning } 167 : { success: true, version: result[0].version }; ··· 172 const db = getDb(); 173 174 await db.delete(drafts).where(and(eq(drafts.authorDid, did), eq(drafts.rkey, rkey))); 175 }
··· 2 3 import "server-only"; 4 import { getDb, drafts, type DraftRecord } from "@/data/db"; 5 + import { refresh, revalidatePath } from "next/cache"; 6 import { eq, and, sql } from "drizzle-orm"; 7 import { getCurrentDid } from "@/auth"; 8 import { generateTid } from "../tid"; ··· 103 version: 1, 104 }); 105 106 + revalidatePath("/drafts"); 107 return rkey; 108 } 109 ··· 164 }) 165 .returning({ version: drafts.version }); 166 167 + revalidatePath("/drafts"); 168 + 169 return warning 170 ? { success: true, version: result[0].version, warning } 171 : { success: true, version: result[0].version }; ··· 176 const db = getDb(); 177 178 await db.delete(drafts).where(and(eq(drafts.authorDid, did), eq(drafts.rkey, rkey))); 179 + refresh(); 180 }