tangled
alpha
login
or
join now
danabra.mov
/
sidetrail
49
fork
atom
an app to share curated trails
sidetrail.app
atproto
nextjs
react
rsc
49
fork
atom
overview
issues
pulls
pipelines
make things more transitioney
danabra.mov
3 months ago
a4b826f7
82cf3ef2
+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
···
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
0
0
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) => {
0
0
0
0
0
0
0
0
0
0
0
14
await deleteDraft(rkey);
0
0
0
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
0
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) => {
0
0
15
await abandonWalk(walkUri);
0
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);
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
3
}
4
5
.HomeWalkingPill {
0
0
0
0
0
0
6
display: flex;
7
+
flex-direction: column;
8
+
gap: 0.375rem;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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";
0
0
0
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) {
0
0
29
const handleDelete = (e: React.MouseEvent) => {
30
e.preventDefault();
31
e.stopPropagation();
32
if (confirm(deleteConfirmMessage)) {
33
-
onDelete?.();
0
0
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}
0
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
+
))}
0
0
0
0
0
0
0
0
0
0
0
0
0
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;
0
12
}
13
14
export function FloatingAvatar({
···
17
title,
18
contained = false,
19
opaque = false,
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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}
0
0
0
0
0
0
0
0
0
0
84
</Link>
85
);
86
}
+2
-1
app/HomeTrailsList.tsx
···
1
import type { TrailCardData } from "../data/queries";
2
import { TrailCard } from "./TrailCard";
0
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}
0
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
0
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";
0
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
0
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();
0
16
17
+
const createAction = async () => {
18
requireAuth();
19
+
const rkey = await createDraft();
20
+
router.push(`/drafts/${rkey}`);
0
0
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
}
0
0
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
26
font-size: 1.125rem;
27
color: var(--text-tertiary);
28
cursor: pointer;
0
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 {
0
0
0
0
47
color: var(--text-primary);
0
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;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
-
))}
0
0
0
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
.TrailCard {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2
display: flex;
3
flex-direction: column;
4
gap: 0.75rem;
5
}
6
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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;
0
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,
0
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
}
···
0
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({
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>
0
0
0
0
0
0
0
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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";
0
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}
0
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}
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
···
0
0
0
1
import "./AccentButton.css";
2
3
type Props = {
4
children: React.ReactNode;
5
-
onClick?: () => void;
0
6
disabled?: boolean;
7
type?: "button" | "submit";
8
size?: "medium" | "large";
···
10
11
export function AccentButton({
12
children,
13
-
onClick,
0
14
disabled = false,
15
type = "button",
16
size = "large",
17
}: Props) {
18
-
const className = `AccentButton AccentButton--${size}`;
0
0
0
0
0
0
0
0
0
0
0
0
19
20
return (
21
-
<button type={type} onClick={onClick} disabled={disabled} className={className}>
22
-
{children}
0
0
0
0
0
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 {
0
114
font-size: 0.875rem;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
115
}
116
117
/* Edit mode styles */
+26
-21
app/at/(trail)/[handle]/trail/[rkey]/TrailOverview.tsx
···
1
import { useRouter } from "next/navigation";
0
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";
0
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";
0
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>
0
0
293
)}
294
{!isEditing && trail.yourWalk && (
295
-
<button onClick={handleAbandon} className="TrailOverview-abandonButton">
296
-
abandon
297
-
</button>
0
0
298
)}
299
{!isEditing && !trail.yourWalk && canEdit && (
300
-
<button onClick={handleDeleteTrail} className="TrailOverview-abandonButton">
301
-
delete trail
302
-
</button>
0
0
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";
0
0
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;
0
23
};
24
25
export function TrailOverview({
···
29
onDelete,
30
onPublish,
31
publishError,
0
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 {
0
185
font-size: 0.6875rem;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
186
}
187
188
/* Tablet and desktop enhancements */
+7
-4
app/at/(trail)/[handle]/trail/[rkey]/TrailProgress.tsx
···
1
import Link from "next/link";
0
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>
0
0
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">
0
0
0
0
0
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 {
0
146
font-size: 0.875rem;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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";
0
0
0
0
0
0
0
0
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";
0
0
10
import { visitStop, completeTrail, abandonWalk, deleteCompletion } from "@/data/actions";
11
import { useAuthAction } from "@/auth/useAuthAction";
12
import type { EmbedCache } from "./StopEmbed";
0
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");
0
0
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>
0
0
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";
0
0
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;
0
51
initialEmbeds?: Array<[string, Promise<React.ReactElement>]>;
52
};
53
···
57
isEditMode,
58
onPublish,
59
publishError,
0
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
0
0
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";
0
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}
0
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}
0
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
0
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({});
0
208
209
try {
210
await saver.saveNow(localDraft);
···
220
if (!result.success) {
221
setPublishError(result.errors);
222
setInlineErrors(result.inlineErrors);
0
223
return;
224
}
225
···
228
} catch (error: unknown) {
229
const message = error instanceof Error ? error.message : "something went wrong";
230
setPublishError([message]);
0
231
}
232
};
233
···
325
rkey={rkey}
326
onModeChange={() => setStage("overview")}
327
isEditMode={true}
328
+
onPublish={publishAction}
329
publishError={publishError}
0
330
initialEmbeds={initialEmbeds}
331
/>
332
)}
+46
components/ActionButton.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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";
0
5
import { eq, and, sql } from "drizzle-orm";
6
import { getCurrentDid } from "@/auth";
7
import { generateTid } from "../tid";
···
102
version: 1,
103
});
104
0
105
return rkey;
106
}
107
···
162
})
163
.returning({ version: drafts.version });
164
0
0
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)));
0
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
}