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