whereto-bike 0.0.3 → 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.
Files changed (41) hide show
  1. package/aws/video-agent/package-lock.json +3 -3
  2. package/package.json +3 -2
  3. package/scripts/patch-astro-renderers.js +40 -0
  4. package/src/components/EditEntryPoint.astro +17 -0
  5. package/src/components/MediaGallery.astro +160 -0
  6. package/src/components/NearbyPlaces.astro +10 -1
  7. package/src/components/NearbyPlaces.scss +58 -10
  8. package/src/components/ReactionsWidget.tsx +62 -56
  9. package/src/components/RideDetailContent.astro +2 -5
  10. package/src/components/TagFilter.astro +0 -13
  11. package/src/components/TrustReceipt.tsx +22 -0
  12. package/src/components/VariantCards.astro +81 -0
  13. package/src/components/VideoPlayer.tsx +81 -0
  14. package/src/components/WikiHome.astro +4 -4
  15. package/src/components/admin/AuthGate.tsx +67 -16
  16. package/src/components/admin/EditorActions.tsx +5 -2
  17. package/src/components/admin/EditorFocusWrapper.tsx +45 -0
  18. package/src/components/admin/EventEditor.tsx +1 -0
  19. package/src/components/admin/PlaceEditor.tsx +87 -25
  20. package/src/components/admin/RidePreview.tsx +9 -7
  21. package/src/components/admin/RouteEditor.tsx +62 -1
  22. package/src/components/admin/RoutePreview.tsx +79 -0
  23. package/src/components/admin/SlugEditor.tsx +3 -3
  24. package/src/i18n/en.json +36 -3
  25. package/src/i18n/es.json +35 -3
  26. package/src/i18n/fr.json +36 -3
  27. package/src/integrations/AGENTS.md +8 -0
  28. package/src/integrations/i18n-routes.ts +2 -0
  29. package/src/styles/_editor.scss +8 -4
  30. package/src/styles/admin.scss +217 -22
  31. package/src/styles/global.scss +179 -11
  32. package/src/views/about.astro +14 -6
  33. package/src/views/admin/place-new.astro +5 -1
  34. package/src/views/admin/route-detail.astro +10 -1
  35. package/src/views/auth/gate.astro +14 -8
  36. package/src/views/events/club-detail.astro +4 -1
  37. package/src/views/places/index.astro +10 -0
  38. package/src/views/routes/detail.astro +88 -108
  39. package/src/views/routes/route-data.json.ts +183 -0
  40. package/src/views/routes/routes-index.json.ts +73 -0
  41. 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.1009.0",
141
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-mediaconvert/-/client-mediaconvert-3.1009.0.tgz",
142
- "integrity": "sha512-fUb16VXk1OcaLq1DQJ2gfacegFBRryc9F7P2xGaBqlnFSe24UrZ2ElOEZLgMvJD9hHfn7yGCjBylSiftk4e7HA==",
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.3",
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.58.2",
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
- // Nearby places
5
- .nearby-places ul {
6
- list-style: none;
7
- padding: 0;
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
- .nearby-places li {
10
- padding: 0.3em 0;
11
- display: flex;
12
- align-items: center;
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
- opacity: 0.7;
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
- const PENDING_KEY = 'pending_reaction';
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 pendingProcessed = useRef(false);
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
- const res = await fetch('/api/reactions', {
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
- storePendingReaction(contentType, contentSlug, reactionType);
81
- window.location.href = `/gate?returnTo=${encodeURIComponent(window.location.pathname)}`;
82
- return;
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
- {labels.map(({ type, icon, label, title }) => {
119
- const count = counts[type] || 0;
120
- const active = userReactions.includes(type);
121
- const isAnimating = animating === type;
122
- return (
123
- <button
124
- key={type}
125
- type="button"
126
- class={`reaction-btn ${active ? 'active' : ''} ${isAnimating ? 'pop' : ''}`}
127
- onClick={() => toggleReaction(type)}
128
- title={title || label}
129
- >
130
- <span class={`reaction-icon ${isAnimating ? 'pop' : ''}`}>{icon}</span>
131
- <span class="reaction-label">{label}</span>
132
- {count > 0 && <span class="reaction-count">{count}</span>}
133
- </button>
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 PhotoGallery from '@/components/PhotoGallery.astro';
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
- {photos.length > 0 && (
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
+ }