whereto-bike 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/aws/video-agent/package-lock.json +3 -3
- package/package.json +3 -2
- package/scripts/patch-astro-renderers.js +40 -0
- package/src/components/EditEntryPoint.astro +17 -0
- package/src/components/MediaGallery.astro +160 -0
- package/src/components/NearbyPlaces.astro +10 -1
- package/src/components/NearbyPlaces.scss +58 -10
- package/src/components/ReactionsWidget.tsx +62 -56
- package/src/components/RideDetailContent.astro +2 -5
- package/src/components/TagFilter.astro +0 -13
- package/src/components/TrustReceipt.tsx +22 -0
- package/src/components/VariantCards.astro +81 -0
- package/src/components/VideoPlayer.tsx +81 -0
- package/src/components/WikiHome.astro +4 -4
- package/src/components/admin/AuthGate.tsx +67 -16
- package/src/components/admin/EditorActions.tsx +5 -2
- package/src/components/admin/EditorFocusWrapper.tsx +45 -0
- package/src/components/admin/EventEditor.tsx +1 -0
- package/src/components/admin/PlaceEditor.tsx +87 -25
- package/src/components/admin/RidePreview.tsx +9 -7
- package/src/components/admin/RouteEditor.tsx +62 -1
- package/src/components/admin/RoutePreview.tsx +79 -0
- package/src/components/admin/SlugEditor.tsx +3 -3
- package/src/i18n/en.json +36 -3
- package/src/i18n/es.json +35 -3
- package/src/i18n/fr.json +36 -3
- package/src/integrations/AGENTS.md +8 -0
- package/src/integrations/i18n-routes.ts +2 -0
- package/src/styles/_editor.scss +8 -4
- package/src/styles/admin.scss +217 -22
- package/src/styles/global.scss +179 -11
- package/src/views/about.astro +14 -6
- package/src/views/admin/place-new.astro +5 -1
- package/src/views/admin/route-detail.astro +10 -1
- package/src/views/auth/gate.astro +14 -8
- package/src/views/events/club-detail.astro +4 -1
- package/src/views/places/index.astro +10 -0
- package/src/views/routes/detail.astro +88 -108
- package/src/views/routes/route-data.json.ts +183 -0
- package/src/views/routes/routes-index.json.ts +73 -0
- package/src/components/PhotoGallery.astro +0 -114
|
@@ -137,9 +137,9 @@
|
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
139
|
"node_modules/@aws-sdk/client-mediaconvert": {
|
|
140
|
-
"version": "3.
|
|
141
|
-
"resolved": "https://registry.npmjs.org/@aws-sdk/client-mediaconvert/-/client-mediaconvert-3.
|
|
142
|
-
"integrity": "sha512-
|
|
140
|
+
"version": "3.1010.0",
|
|
141
|
+
"resolved": "https://registry.npmjs.org/@aws-sdk/client-mediaconvert/-/client-mediaconvert-3.1010.0.tgz",
|
|
142
|
+
"integrity": "sha512-GrY3XZUhmmlWn69Plo6epoLBiZpQWtV0IlmyvOXPTLyyp+Vxjdf1w7fZcPjkJeC+OsnjURTAiIqVrasAyEYNFw==",
|
|
143
143
|
"license": "Apache-2.0",
|
|
144
144
|
"dependencies": {
|
|
145
145
|
"@aws-crypto/sha256-browser": "5.2.0",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whereto-bike",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.5",
|
|
5
5
|
"description": "Open-source cycling platform — the CMS for cycling",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
7
7
|
"repository": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"packages/create-bike-blog"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
+
"postinstall": "node scripts/patch-astro-renderers.js",
|
|
37
38
|
"dev": "astro dev",
|
|
38
39
|
"build": "tsx scripts/build-map-style.ts && astro build",
|
|
39
40
|
"preview": "astro preview",
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
},
|
|
78
79
|
"devDependencies": {
|
|
79
80
|
"@eslint/js": "^10.0.1",
|
|
80
|
-
"@playwright/test": "^1.
|
|
81
|
+
"@playwright/test": "^1.57.0",
|
|
81
82
|
"@types/better-sqlite3": "^7.6.13",
|
|
82
83
|
"@types/js-yaml": "^4.0.9",
|
|
83
84
|
"@types/mapbox__polyline": "^1.0.5",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Patches Astro's vite-plugin-renderers to not strip SSR renderers when all
|
|
3
|
+
// routes are injected (origin:"external") rather than file-based (origin:"project").
|
|
4
|
+
//
|
|
5
|
+
// The bug: Astro 6's hasNonPrerenderedProjectRoute() only checks origin:"project"
|
|
6
|
+
// routes. Since this project uses injectRoute() for all routes, Astro thinks there
|
|
7
|
+
// are no SSR pages and strips the Preact renderer — causing NoMatchingRenderer
|
|
8
|
+
// errors on every SSR page (/gate, /register, admin pages).
|
|
9
|
+
//
|
|
10
|
+
// This patch disables the optimization by replacing the condition with `false`.
|
|
11
|
+
// It's safe because the only effect of the optimization is a smaller bundle when
|
|
12
|
+
// renderers aren't needed — and we always need them.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const file = resolve('node_modules/astro/dist/vite-plugin-renderers/index.js');
|
|
18
|
+
const marker = '!hasNonPrerenderedProjectRoute(options.routesList.routes';
|
|
19
|
+
|
|
20
|
+
let code;
|
|
21
|
+
try {
|
|
22
|
+
code = readFileSync(file, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
// astro not installed yet (e.g. during initial npm install)
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (code.includes('false && ' + marker)) {
|
|
29
|
+
console.log('[patch-astro-renderers] Already patched.');
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!code.includes(marker)) {
|
|
34
|
+
console.warn('[patch-astro-renderers] Could not find the optimization to patch — Astro may have fixed the bug.');
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patched = code.replace(marker, 'false && ' + marker);
|
|
39
|
+
writeFileSync(file, patched);
|
|
40
|
+
console.log('[patch-astro-renderers] Patched: disabled renderer stripping for injected routes.');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getInstanceFeatures } from '../lib/config/instance-features';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
text: string;
|
|
6
|
+
href: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { text, href } = Astro.props;
|
|
10
|
+
const features = getInstanceFeatures();
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
{features.showsContributeLink && (
|
|
14
|
+
<p class="edit-entry-point">
|
|
15
|
+
<a href={href}>{text}</a>
|
|
16
|
+
</p>
|
|
17
|
+
)}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './PhotoGallery.scss';
|
|
3
|
+
import { imageUrl } from '../lib/media/image-service';
|
|
4
|
+
import { videoPlaybackSources, videoPosterUrl, videoDisplaySize, videoFallbackUrl } from '../lib/media/video-service';
|
|
5
|
+
import ResponsiveImage from './ResponsiveImage.astro';
|
|
6
|
+
import VideoPlayer from './VideoPlayer.tsx';
|
|
7
|
+
import { t } from '../i18n';
|
|
8
|
+
import { paths } from '../lib/paths';
|
|
9
|
+
import { isBlogInstance } from '../lib/config/city-config';
|
|
10
|
+
|
|
11
|
+
const locale = Astro.currentLocale;
|
|
12
|
+
|
|
13
|
+
interface MediaItem {
|
|
14
|
+
type: string;
|
|
15
|
+
key: string;
|
|
16
|
+
caption?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
handle?: string;
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
score?: number;
|
|
22
|
+
duration?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
media: MediaItem[];
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { media, name } = Astro.props;
|
|
31
|
+
const photos = media.filter(m => m.type === 'photo').sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
32
|
+
const videos = media.filter(m => m.type === 'video');
|
|
33
|
+
|
|
34
|
+
// Rails: Gallery.from_photos only shows photos with score >= 1, no fallback (empty gallery if none qualify).
|
|
35
|
+
// We add a fallback to all photos in case scores haven't been set yet.
|
|
36
|
+
const scored = photos.filter(p => (p.score || 0) >= 1);
|
|
37
|
+
const allPhotos = scored.length > 0 ? scored : photos;
|
|
38
|
+
const VISIBLE_COUNT = 9;
|
|
39
|
+
const hasMore = allPhotos.length > VISIBLE_COUNT;
|
|
40
|
+
|
|
41
|
+
const hasVideoPages = !isBlogInstance();
|
|
42
|
+
|
|
43
|
+
function thumbDimensions(photo: MediaItem, targetWidth: number) {
|
|
44
|
+
if (!photo.width || !photo.height) return {};
|
|
45
|
+
const scale = targetWidth / photo.width;
|
|
46
|
+
return { width: targetWidth, height: Math.round(photo.height * scale) };
|
|
47
|
+
}
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
{allPhotos.length > 0 && (
|
|
51
|
+
<section class="route-photos">
|
|
52
|
+
<h2>Photos</h2>
|
|
53
|
+
<div class="photo-gallery">
|
|
54
|
+
{allPhotos.map((photo, i) => (
|
|
55
|
+
<a
|
|
56
|
+
href={imageUrl(photo.key)}
|
|
57
|
+
data-pswp-width={photo.width}
|
|
58
|
+
data-pswp-height={photo.height}
|
|
59
|
+
data-cropped="true"
|
|
60
|
+
class={`photo-gallery--image${i >= VISIBLE_COUNT ? ' photo-hidden' : ''}`}
|
|
61
|
+
target="_blank"
|
|
62
|
+
>
|
|
63
|
+
<ResponsiveImage
|
|
64
|
+
blobKey={photo.key}
|
|
65
|
+
alt={photo.caption || name}
|
|
66
|
+
widths={[375, 750, 1170]}
|
|
67
|
+
width={thumbDimensions(photo, 375).width}
|
|
68
|
+
height={thumbDimensions(photo, 375).height}
|
|
69
|
+
/>
|
|
70
|
+
{photo.caption && (
|
|
71
|
+
<span class="photo-gallery--caption pswp-caption-content">{photo.caption}</span>
|
|
72
|
+
)}
|
|
73
|
+
</a>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
{hasMore && (
|
|
77
|
+
<button class="action-button" id="show-all-photos">
|
|
78
|
+
{t('photos.show_all', locale, { count: allPhotos.length })}
|
|
79
|
+
</button>
|
|
80
|
+
)}
|
|
81
|
+
</section>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{videos.length > 0 && (
|
|
85
|
+
<section class="route-videos">
|
|
86
|
+
<h3>{t('videos.title', locale)}</h3>
|
|
87
|
+
{videos.map(video => {
|
|
88
|
+
const sources = videoPlaybackSources(video.key);
|
|
89
|
+
const size = videoDisplaySize(video.width || 640, video.height || 360);
|
|
90
|
+
return (
|
|
91
|
+
<div class="route-video">
|
|
92
|
+
<VideoPlayer
|
|
93
|
+
client:idle
|
|
94
|
+
sources={sources}
|
|
95
|
+
poster={videoPosterUrl(video.key)}
|
|
96
|
+
fallbackUrl={videoFallbackUrl(video.key)}
|
|
97
|
+
width={size.width}
|
|
98
|
+
height={size.height}
|
|
99
|
+
title={video.title}
|
|
100
|
+
/>
|
|
101
|
+
{video.title && hasVideoPages && video.handle && (
|
|
102
|
+
<p><a href={paths.video(video.handle, locale)}>{video.title}</a></p>
|
|
103
|
+
)}
|
|
104
|
+
{video.title && !hasVideoPages && (
|
|
105
|
+
<p class="video-title">{video.title}</p>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</section>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{(allPhotos.length > 0) && (
|
|
114
|
+
<script>
|
|
115
|
+
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
|
116
|
+
import 'photoswipe/style.css';
|
|
117
|
+
import 'photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css';
|
|
118
|
+
import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin';
|
|
119
|
+
import ObjectPosition from '@vovayatsyuk/photoswipe-object-position';
|
|
120
|
+
|
|
121
|
+
const isMobile = window.innerWidth < 768;
|
|
122
|
+
const lightboxOptions: any = {
|
|
123
|
+
gallery: '.photo-gallery',
|
|
124
|
+
children: 'a.photo-gallery--image',
|
|
125
|
+
pswpModule: () => import('photoswipe'),
|
|
126
|
+
};
|
|
127
|
+
if (!isMobile) {
|
|
128
|
+
lightboxOptions.paddingFn = () => ({ top: 30, bottom: 30, left: 70, right: 70 });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const lightbox = new PhotoSwipeLightbox(lightboxOptions);
|
|
132
|
+
new ObjectPosition(lightbox);
|
|
133
|
+
new PhotoSwipeDynamicCaption(lightbox, { type: 'auto' });
|
|
134
|
+
lightbox.init();
|
|
135
|
+
lightbox.on('beforeOpen', () => {
|
|
136
|
+
window.BikeApp?.tE?.('gallery open', { props: { page: window.location.pathname } });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Cover photo click opens first gallery image
|
|
140
|
+
const coverPhoto = document.querySelector('.gallery-cover-photo');
|
|
141
|
+
const firstPhoto = document.querySelector('.photo-gallery a.photo-gallery--image:first-child');
|
|
142
|
+
if (coverPhoto && firstPhoto) {
|
|
143
|
+
coverPhoto.addEventListener('click', () => {
|
|
144
|
+
window.BikeApp?.tE?.('click cover photo', { props: { page: window.location.pathname } });
|
|
145
|
+
(firstPhoto as HTMLElement).click();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const showAllBtn = document.getElementById('show-all-photos');
|
|
150
|
+
if (showAllBtn) {
|
|
151
|
+
showAllBtn.addEventListener('click', () => {
|
|
152
|
+
document.querySelectorAll('.photo-hidden').forEach(el => {
|
|
153
|
+
(el as HTMLElement).classList.remove('photo-hidden');
|
|
154
|
+
});
|
|
155
|
+
showAllBtn.style.display = 'none';
|
|
156
|
+
window.BikeApp?.tE?.('show more photos', { props: { page: window.location.pathname } });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
</script>
|
|
160
|
+
)}
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
---
|
|
2
|
+
import './NearbyPlaces.scss';
|
|
2
3
|
import type { NearbyPlace } from '../lib/geo/proximity';
|
|
3
4
|
import { categoryEmoji } from '../lib/geo/place-categories';
|
|
4
5
|
import { defaultLocale } from '../lib/i18n/locale-utils';
|
|
5
6
|
import { t } from '../i18n';
|
|
7
|
+
import { getInstanceFeatures } from '../lib/config/instance-features';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
nearby: NearbyPlace[];
|
|
9
11
|
limit?: number;
|
|
12
|
+
routeSlug?: string;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
const { nearby, limit } = Astro.props;
|
|
15
|
+
const { nearby, limit, routeSlug } = Astro.props;
|
|
13
16
|
const locale = Astro.currentLocale;
|
|
14
17
|
const hasMore = limit != null && nearby.length > limit;
|
|
18
|
+
const features = getInstanceFeatures();
|
|
15
19
|
---
|
|
16
20
|
|
|
17
21
|
{nearby.length > 0 && (
|
|
@@ -45,6 +49,11 @@ const hasMore = limit != null && nearby.length > limit;
|
|
|
45
49
|
{t('places.show_all', locale, { count: nearby.length })}
|
|
46
50
|
</button>
|
|
47
51
|
)}
|
|
52
|
+
{features.showsContributeLink && routeSlug && (
|
|
53
|
+
<p class="nearby-places-add">
|
|
54
|
+
<a href={`/admin/places/new?near=${routeSlug}`}>{t('places.add_near_route_long', locale)}</a>
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
48
57
|
</section>
|
|
49
58
|
)}
|
|
50
59
|
|
|
@@ -1,25 +1,73 @@
|
|
|
1
1
|
@use '../styles/variables' as *;
|
|
2
2
|
@use '../styles/mixins' as *;
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
.nearby-places {
|
|
5
|
+
h2 {
|
|
6
|
+
font-size: 1.2em;
|
|
7
|
+
margin-bottom: 0.5em;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
ul {
|
|
11
|
+
list-style: none;
|
|
12
|
+
padding: 0;
|
|
13
|
+
margin: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
li {
|
|
17
|
+
padding: 0.4em 0;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: baseline;
|
|
20
|
+
gap: 0.4em;
|
|
21
|
+
line-height: 1.4;
|
|
22
|
+
}
|
|
8
23
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
gap: 0.5em;
|
|
24
|
+
|
|
25
|
+
.place-emoji {
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
font-size: 1.1em;
|
|
14
28
|
}
|
|
29
|
+
|
|
15
30
|
.place-name {
|
|
16
31
|
text-decoration: none;
|
|
17
32
|
color: inherit;
|
|
33
|
+
|
|
18
34
|
&:hover {
|
|
19
35
|
text-decoration: underline;
|
|
20
36
|
}
|
|
21
37
|
}
|
|
38
|
+
|
|
22
39
|
.place-link {
|
|
23
40
|
font-size: 0.85em;
|
|
24
|
-
|
|
41
|
+
@include muted-text;
|
|
42
|
+
text-decoration: none;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
|
|
45
|
+
&::before {
|
|
46
|
+
content: '·';
|
|
47
|
+
margin-right: 0.3em;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
&:hover {
|
|
51
|
+
text-decoration: underline;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.places-hidden {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.nearby-places-summary {
|
|
60
|
+
@include muted-text;
|
|
61
|
+
font-size: 0.9em;
|
|
62
|
+
margin-top: 0.6em;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.nearby-places-add {
|
|
66
|
+
@include muted-text;
|
|
67
|
+
font-size: 0.9em;
|
|
68
|
+
margin-top: 0.8em;
|
|
69
|
+
|
|
70
|
+
a {
|
|
71
|
+
color: inherit;
|
|
72
|
+
}
|
|
25
73
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
|
2
|
+
import TrustReceipt from './TrustReceipt';
|
|
2
3
|
|
|
3
4
|
interface ReactionButton {
|
|
4
5
|
type: string;
|
|
@@ -11,39 +12,21 @@ interface Props {
|
|
|
11
12
|
contentType: 'route' | 'event';
|
|
12
13
|
contentSlug: string;
|
|
13
14
|
labels: ReactionButton[];
|
|
15
|
+
bookmarkHint?: string;
|
|
16
|
+
trustReceiptMessage?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
interface ReactionCounts {
|
|
17
20
|
[key: string]: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
function storePendingReaction(contentType: string, contentSlug: string, reactionType: string) {
|
|
23
|
-
try {
|
|
24
|
-
localStorage.setItem(PENDING_KEY, JSON.stringify({ contentType, contentSlug, reactionType }));
|
|
25
|
-
} catch { /* localStorage unavailable */ }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function consumePendingReaction(contentType: string, contentSlug: string): string | null {
|
|
29
|
-
try {
|
|
30
|
-
const raw = localStorage.getItem(PENDING_KEY);
|
|
31
|
-
if (!raw) return null;
|
|
32
|
-
const pending = JSON.parse(raw);
|
|
33
|
-
if (pending.contentType === contentType && pending.contentSlug === contentSlug) {
|
|
34
|
-
localStorage.removeItem(PENDING_KEY);
|
|
35
|
-
return pending.reactionType;
|
|
36
|
-
}
|
|
37
|
-
} catch { /* ignore */ }
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export default function ReactionsWidget({ contentType, contentSlug, labels }: Props) {
|
|
23
|
+
export default function ReactionsWidget({ contentType, contentSlug, labels, bookmarkHint, trustReceiptMessage }: Props) {
|
|
42
24
|
const [counts, setCounts] = useState<ReactionCounts>({});
|
|
43
25
|
const [userReactions, setUserReactions] = useState<string[]>([]);
|
|
44
26
|
const [loading, setLoading] = useState(true);
|
|
45
27
|
const [animating, setAnimating] = useState<string | null>(null);
|
|
46
|
-
const
|
|
28
|
+
const [showReceipt, setShowReceipt] = useState(false);
|
|
29
|
+
const hasShownReceipt = useRef(false);
|
|
47
30
|
|
|
48
31
|
const fetchReactions = useCallback(async () => {
|
|
49
32
|
try {
|
|
@@ -69,17 +52,42 @@ export default function ReactionsWidget({ contentType, contentSlug, labels }: Pr
|
|
|
69
52
|
setTimeout(() => setAnimating(null), 600);
|
|
70
53
|
};
|
|
71
54
|
|
|
55
|
+
const createSilentGuest = useCallback(async (): Promise<boolean> => {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch('/api/auth/guest', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
});
|
|
61
|
+
return res.ok;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
72
67
|
const toggleReaction = useCallback(async (reactionType: string) => {
|
|
73
|
-
|
|
68
|
+
let res = await fetch('/api/reactions', {
|
|
74
69
|
method: 'POST',
|
|
75
70
|
headers: { 'Content-Type': 'application/json' },
|
|
76
71
|
body: JSON.stringify({ contentType, contentSlug, reactionType }),
|
|
77
72
|
});
|
|
78
73
|
|
|
74
|
+
// Silent guest creation on 401
|
|
79
75
|
if (res.status === 401) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
const created = await createSilentGuest();
|
|
77
|
+
if (!created) return;
|
|
78
|
+
|
|
79
|
+
// Retry the reaction with the new session cookie
|
|
80
|
+
res = await fetch('/api/reactions', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ contentType, contentSlug, reactionType }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Show trust receipt on first silent guest creation
|
|
87
|
+
if (res.ok && !hasShownReceipt.current) {
|
|
88
|
+
hasShownReceipt.current = true;
|
|
89
|
+
setShowReceipt(true);
|
|
90
|
+
}
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
if (res.ok) {
|
|
@@ -99,40 +107,38 @@ export default function ReactionsWidget({ contentType, contentSlug, labels }: Pr
|
|
|
99
107
|
setUserReactions(prev => prev.filter(r => r !== reactionType));
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
|
-
}, [contentType, contentSlug]);
|
|
103
|
-
|
|
104
|
-
// After loading, check for a pending reaction (user just returned from gate)
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (loading || pendingProcessed.current) return;
|
|
107
|
-
pendingProcessed.current = true;
|
|
108
|
-
const pending = consumePendingReaction(contentType, contentSlug);
|
|
109
|
-
if (pending) {
|
|
110
|
-
toggleReaction(pending);
|
|
111
|
-
}
|
|
112
|
-
}, [loading, contentType, contentSlug, toggleReaction]);
|
|
110
|
+
}, [contentType, contentSlug, createSilentGuest]);
|
|
113
111
|
|
|
114
112
|
if (loading) return null;
|
|
115
113
|
|
|
116
114
|
return (
|
|
117
115
|
<div class="reactions-widget">
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
<div class="reactions-buttons">
|
|
117
|
+
{labels.map(({ type, icon, label, title }) => {
|
|
118
|
+
const count = counts[type] || 0;
|
|
119
|
+
const active = userReactions.includes(type);
|
|
120
|
+
const isAnimating = animating === type;
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
key={type}
|
|
124
|
+
type="button"
|
|
125
|
+
class={`reaction-btn ${active ? 'active' : ''} ${isAnimating ? 'pop' : ''}`}
|
|
126
|
+
onClick={() => toggleReaction(type)}
|
|
127
|
+
title={title || label}
|
|
128
|
+
>
|
|
129
|
+
<span class={`reaction-icon ${isAnimating ? 'pop' : ''}`}>{icon}</span>
|
|
130
|
+
<span class="reaction-label">{label}</span>
|
|
131
|
+
{count > 0 && <span class="reaction-count">{count}</span>}
|
|
132
|
+
</button>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
{showReceipt && trustReceiptMessage && (
|
|
137
|
+
<TrustReceipt message={trustReceiptMessage} />
|
|
138
|
+
)}
|
|
139
|
+
{bookmarkHint && (
|
|
140
|
+
<p class="reaction-hint">{bookmarkHint}</p>
|
|
141
|
+
)}
|
|
136
142
|
</div>
|
|
137
143
|
);
|
|
138
144
|
}
|
|
@@ -6,7 +6,7 @@ import { formatDuration } from '@/lib/date-utils';
|
|
|
6
6
|
import { formatDistance, formatElevation, formatSpeed } from '@/lib/format';
|
|
7
7
|
import { paths, assets } from '@/lib/paths';
|
|
8
8
|
import { countryToFlag } from '@/lib/country-flags';
|
|
9
|
-
import
|
|
9
|
+
import MediaGallery from '@/components/MediaGallery.astro';
|
|
10
10
|
import ContentDetail from '@/components/patterns/ContentDetail.astro';
|
|
11
11
|
import StatBar from '@/components/StatBar.astro';
|
|
12
12
|
import NearbyPlaces from '@/components/NearbyPlaces.astro';
|
|
@@ -26,7 +26,6 @@ const { name, distance_km, media, gpxTracks, renderedBody } = route.data;
|
|
|
26
26
|
const locale = Astro.currentLocale;
|
|
27
27
|
|
|
28
28
|
const cover = media.find((m: any) => m.cover);
|
|
29
|
-
const photos = media.filter((m: any) => m.type === 'photo').sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
|
|
30
29
|
const hasMap = hasCachedMap(route.id);
|
|
31
30
|
const mapLang = cachedMapLocale(route.id, undefined, locale);
|
|
32
31
|
|
|
@@ -157,9 +156,7 @@ const timeDisplay = route.data.elapsed_time_s && route.data.moving_time_s
|
|
|
157
156
|
</Fragment>
|
|
158
157
|
|
|
159
158
|
<Fragment slot="gallery">
|
|
160
|
-
{
|
|
161
|
-
<PhotoGallery photos={photos} routeName={name} />
|
|
162
|
-
)}
|
|
159
|
+
<MediaGallery media={media} name={name} />
|
|
163
160
|
</Fragment>
|
|
164
161
|
|
|
165
162
|
<Fragment slot="after">
|
|
@@ -104,18 +104,5 @@ const locale = Astro.currentLocale;
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Hide welcome message after 3+ visits
|
|
108
|
-
function setupHomeMessageHider() {
|
|
109
|
-
const messages = document.querySelectorAll('.welcome-message, .tag-filter--label');
|
|
110
|
-
if (!messages.length) return;
|
|
111
|
-
const key = 'welcomeMessageShownV2';
|
|
112
|
-
let visits = parseInt(localStorage.getItem(key) || '0');
|
|
113
|
-
if (visits >= 3) {
|
|
114
|
-
messages.forEach(el => (el as HTMLElement).style.display = 'none');
|
|
115
|
-
}
|
|
116
|
-
localStorage.setItem(key, String(visits + 1));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
107
|
setupTagFiltering();
|
|
120
|
-
setupHomeMessageHider();
|
|
121
108
|
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useState, useEffect } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default function TrustReceipt({ message }: Props) {
|
|
8
|
+
const [visible, setVisible] = useState(true);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const timer = setTimeout(() => setVisible(false), 4000);
|
|
12
|
+
return () => clearTimeout(timer);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
if (!visible) return null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<span class="trust-receipt" role="status" aria-live="polite">
|
|
19
|
+
{message}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|