Skip to content

Commit d8a8fb8

Browse files
committed
feat: render consultation panels as bottom sheet drawers on mobile
Convert DetailPanel, LayerControlsPanel, and CommentsOverviewSheet to use vaul Drawer on mobile (<768px) instead of side Sheet/Dialog. The welcome dialog switches to Credenza for automatic desktop/mobile adaptation. - Non-modal drawers keep map interactive behind the sheet - Only one bottom sheet open at a time (opening detail closes controls) - Map zoom padding shifts content upward when drawer is open - ViewToggleButton FAB repositions above any open drawer - LayerControlsButton shows compact icon-only variant on mobile - DrawerContent gains hideOverlay prop for non-modal usage
1 parent 7316cb4 commit d8a8fb8

File tree

11 files changed

+846
-109
lines changed

11 files changed

+846
-109
lines changed

docs/guides/consultations.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ For local development, you can place regulation JSON files in the `public/` dire
272272
9. Export produces a complete updated `regulation.json` merging local edits with original data
273273
10. Only super-administrators can access editing mode (via a small edit icon in the community picker header)
274274

275+
### Mobile Experience
276+
1. On mobile (<768px), overlay panels render as bottom sheet drawers (via vaul) instead of side sheets / dialogs — providing a native iOS-style feel
277+
2. The welcome dialog uses Credenza (Dialog on desktop, Drawer on mobile) for automatic switching
278+
3. DetailPanel, CommentsOverviewSheet, and LayerControlsPanel use an inline `if (isMobile)` pattern: `Sheet` on desktop, `Drawer` on mobile
279+
4. Non-modal drawers (`DetailPanel`, `LayerControlsPanel`) keep the map interactive behind the sheet; modal drawers (`CommentsOverviewSheet`) block interaction
280+
5. Only one bottom sheet is open at a time — opening a detail panel auto-closes the layer controls
281+
6. Map zoom padding shifts content upward on mobile when a drawer is open so geometries aren't hidden behind the bottom sheet
282+
7. The `ViewToggleButton` FAB repositions above any open drawer to avoid overlap
283+
275284
### Navigation
276285
1. URL hash anchors (`#chapter-1`, `#article-3`, `#geoset-prohibited_areas`) enable deep linking to specific entities
277286
2. `{REF:id}` links in markdown content navigate to the referenced entity, switching between document and map views as needed

src/components/consultations/CommentsOverviewSheet.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { useState, useEffect } from "react";
44
import { useSession } from "next-auth/react";
55
import sanitizeHtml from 'sanitize-html';
66
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
7+
import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/components/ui/drawer";
78
import { Button } from "@/components/ui/button";
89
import { Badge } from "@/components/ui/badge";
910
import { Separator } from "@/components/ui/separator";
1011
import { Heart, MessageSquare, ChevronDown, Clock, TrendingUp, ChevronUp } from "lucide-react";
1112
import { formatDistanceToNow } from "date-fns";
1213
import { el } from "date-fns/locale";
1314
import { cn } from "@/lib/utils";
15+
import { useIsMobile } from "@/hooks/use-mobile";
1416
import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations";
1517
import { RegulationData } from "./types";
1618

@@ -33,6 +35,7 @@ export default function CommentsOverviewSheet({
3335
totalCount,
3436
regulationData
3537
}: CommentsOverviewSheetProps) {
38+
const isMobile = useIsMobile();
3639
const { data: session } = useSession();
3740
const [sortBy, setSortBy] = useState<SortOption>('recent');
3841
const [upvoting, setUpvoting] = useState<string | null>(null);
@@ -208,6 +211,175 @@ export default function CommentsOverviewSheet({
208211
return false;
209212
};
210213

214+
const renderContent = () => (
215+
<>
216+
{/* Header */}
217+
<div className={cn("pr-6 flex-shrink-0", isMobile && "px-4")}>
218+
<div className="flex items-center justify-between">
219+
<div>
220+
<div className="text-xs text-muted-foreground font-medium mb-1">
221+
ΣΧΟΛΙΑ
222+
</div>
223+
<div className="text-left text-lg font-semibold">
224+
{totalCount} σχόλια συνολικά
225+
</div>
226+
</div>
227+
</div>
228+
</div>
229+
230+
{/* Sort Controls */}
231+
<div className={cn("flex gap-2 mb-4 flex-shrink-0", isMobile && "px-4")}>
232+
<Button
233+
variant={sortBy === 'recent' ? 'default' : 'outline'}
234+
size="sm"
235+
onClick={() => setSortBy('recent')}
236+
className="flex items-center gap-1"
237+
>
238+
<Clock className="h-3 w-3" />
239+
Πρόσφατα
240+
</Button>
241+
<Button
242+
variant={sortBy === 'liked' ? 'default' : 'outline'}
243+
size="sm"
244+
onClick={() => setSortBy('liked')}
245+
className="flex items-center gap-1"
246+
>
247+
<TrendingUp className="h-3 w-3" />
248+
Δημοφιλή
249+
</Button>
250+
</div>
251+
252+
{/* Comments List */}
253+
<div className={cn("flex-1 overflow-y-auto overscroll-contain space-y-6", isMobile && "px-4")}>
254+
{sortedComments.length === 0 ? (
255+
<div className="text-center py-8 text-muted-foreground">
256+
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
257+
<p>Δεν υπάρχουν σχόλια ακόμα</p>
258+
</div>
259+
) : (
260+
sortedComments.map((comment, index) => (
261+
<div key={comment.id} className="space-y-3">
262+
{/* Reference Box */}
263+
<div
264+
onClick={(e) => handleReferenceClick(e, comment)}
265+
className="bg-muted/30 border border-muted/50 rounded-md p-2 cursor-pointer hover:bg-muted/50 transition-colors"
266+
>
267+
<div className="flex items-center gap-2">
268+
<Badge variant="outline" className="text-xs">
269+
{getEntityTypeLabel(comment.entityType)}
270+
</Badge>
271+
<span className="text-xs text-muted-foreground font-medium">
272+
{getEntityTitle(comment)}
273+
</span>
274+
</div>
275+
</div>
276+
277+
{/* Comment */}
278+
<div className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg">
279+
{/* Upvote Section */}
280+
<div className="flex flex-col items-center gap-1 flex-shrink-0">
281+
<Button
282+
variant="ghost"
283+
size="sm"
284+
className={cn(
285+
"h-6 w-6 p-0",
286+
comment.hasUserUpvoted ? "text-[hsl(var(--orange))]" : "text-muted-foreground"
287+
)}
288+
onClick={(e) => handleUpvote(e, comment.id)}
289+
disabled={!session || upvoting === comment.id}
290+
>
291+
{upvoting === comment.id ? (
292+
<div className="animate-spin rounded-full h-3 w-3 border-b border-current"></div>
293+
) : (
294+
<ChevronUp className="h-4 w-4" />
295+
)}
296+
</Button>
297+
<span className={cn(
298+
"text-xs font-medium",
299+
comment.hasUserUpvoted ? "text-[hsl(var(--orange))]" : "text-muted-foreground"
300+
)}>
301+
{comment.upvoteCount || 0}
302+
</span>
303+
</div>
304+
305+
{/* Comment Content */}
306+
<div className="flex-1 min-w-0 space-y-2">
307+
<div
308+
className="cursor-pointer hover:bg-muted/20 transition-colors rounded p-2 -m-2"
309+
onClick={() => handleCommentClick(comment)}
310+
>
311+
<div className="flex items-center gap-2 mb-2">
312+
<span className="font-medium text-sm">
313+
{comment.user?.name || 'Ανώνυμος'}
314+
</span>
315+
<span className="text-xs text-muted-foreground">
316+
{formatDistanceToNow(new Date(comment.createdAt), {
317+
addSuffix: true,
318+
locale: el
319+
})}
320+
</span>
321+
</div>
322+
<div
323+
className={cn(
324+
"prose prose-sm max-w-none text-sm",
325+
!expandedComments.has(comment.id) && isCommentTruncated(comment.body) && "line-clamp-4"
326+
)}
327+
dangerouslySetInnerHTML={{ __html: getSafeHtmlContent(comment.body) }}
328+
/>
329+
</div>
330+
331+
{/* Show More/Less Button */}
332+
{isCommentTruncated(comment.body) && (
333+
<Button
334+
variant="ghost"
335+
size="sm"
336+
onClick={(e) => {
337+
e.stopPropagation();
338+
toggleCommentExpansion(comment.id);
339+
}}
340+
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
341+
>
342+
<div className="flex items-center gap-1">
343+
{expandedComments.has(comment.id) ? (
344+
<>
345+
<ChevronUp className="h-3 w-3" />
346+
<span>Εμφάνιση λιγότερων</span>
347+
</>
348+
) : (
349+
<>
350+
<ChevronDown className="h-3 w-3" />
351+
<span>Εμφάνιση περισσότερων</span>
352+
</>
353+
)}
354+
</div>
355+
</Button>
356+
)}
357+
</div>
358+
</div>
359+
360+
{/* Separator between comments */}
361+
{index < sortedComments.length - 1 && (
362+
<Separator className="my-2" />
363+
)}
364+
</div>
365+
))
366+
)}
367+
</div>
368+
</>
369+
);
370+
371+
if (isMobile) {
372+
return (
373+
<Drawer open={isOpen} onOpenChange={(open) => !open && onClose()}>
374+
<DrawerContent className="max-h-[85vh] flex flex-col">
375+
<DrawerTitle className="sr-only">{totalCount} σχόλια συνολικά</DrawerTitle>
376+
<DrawerDescription className="sr-only">Επισκόπηση σχολίων</DrawerDescription>
377+
{renderContent()}
378+
</DrawerContent>
379+
</Drawer>
380+
);
381+
}
382+
211383
return (
212384
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
213385
<SheetContent

0 commit comments

Comments
 (0)