search:places

This commit is contained in:
lovebird 2026-04-09 00:11:32 +02:00
parent 854a745505
commit 6dad7aafbf
14 changed files with 113 additions and 26 deletions

View File

@ -21,7 +21,7 @@ interface GalleryLargeProps {
sortBy?: FeedSortOption;
categorySlugs?: string[];
categoryIds?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
preset?: any;

View File

@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react";
import { useFeedData, FeedSortOption } from "@/hooks/useFeedData";
import { useIsMobile } from "@/hooks/use-mobile";
import { useNavigate } from "react-router-dom";
import { useNavigate, Link } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
import { MessageCircle, Heart, ExternalLink } from "lucide-react";
import UserAvatarBlock from "@/components/UserAvatarBlock";
@ -19,7 +19,7 @@ interface ListLayoutProps {
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
categorySlugs?: string[];
categoryIds?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
preset?: any;
@ -106,6 +106,7 @@ const ListItem = ({ item, isSelected, onClick, preset }: { item: any, isSelected
const getSearchGroup = (post: any): string => {
if (post.type === 'page-vfs-folder') return 'Folders';
if (post._searchSource === 'picture') return 'Pictures';
if (post._searchSource === 'place') return 'Places';
if (post._searchSource === 'file') {
if (post.thumbnail_url || post.cover || (post.pictures && post.pictures.length > 0)) return 'Pictures';
return 'Files';
@ -238,7 +239,7 @@ export const ListLayout = ({
groups.get(group)!.push(post);
}
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files'];
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
const elements: React.ReactNode[] = [];
for (const group of orderedGroups) {
@ -311,6 +312,23 @@ export const ListLayout = ({
);
}
if (postAny?.type === 'place-search' && postAny.meta?.url) {
return (
<div className="h-full overflow-y-auto p-6 flex flex-col gap-4">
<h2 className="text-xl font-semibold">{postAny.title}</h2>
{postAny.description && (
<p className="text-muted-foreground text-sm">{postAny.description}</p>
)}
<Link
to={postAny.meta.url}
className="text-primary font-medium hover:underline w-fit"
>
<T>Open place details</T>
</Link>
</div>
);
}
return (
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
<Post

View File

@ -11,6 +11,7 @@ import type { CardPreset } from '@/modules/pages/PageCard';
import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry';
import { getMimeCategory, CATEGORY_STYLE } from '@/modules/storage/helpers';
import type { INode } from '@/modules/storage/types';
import { MapPin } from 'lucide-react';
interface MediaCardProps {
id: string;
@ -178,6 +179,34 @@ const MediaCard: React.FC<MediaCardProps> = ({
}
}
if (normalizedType === MEDIA_TYPES.PLACE_SEARCH) {
return (
<div
onClick={() => onClick?.(id)}
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`}
>
<div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative">
<MapPin className="w-16 h-16 text-primary transition-transform duration-300 group-hover:scale-110" />
</div>
{(preset?.showTitle !== false || preset?.showDescription !== false) && (
<div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none">
{preset?.showTitle !== false && title && (
<h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}>
<span className="truncate">{title}</span>
</h3>
)}
{meta?.placeType && (
<p className="text-xs text-muted-foreground/90 truncate mt-0.5">{meta.placeType}</p>
)}
{preset?.showDescription !== false && description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p>
)}
</div>
)}
</div>
);
}
if (normalizedType === MEDIA_TYPES.PAGE ||
normalizedType === MEDIA_TYPES.PAGE_EXTERNAL ||
normalizedType === 'page-vfs-file' ||
@ -242,6 +271,7 @@ const MediaCard: React.FC<MediaCardProps> = ({
showDescription={showDescription}
showAuthor={showAuthor}
showActions={showActions}
placeTypeLabel={meta?.placeType}
/>
);
};

View File

@ -50,6 +50,8 @@ interface PhotoCardProps {
showActions?: boolean;
showTitle?: boolean;
showDescription?: boolean;
/** Place search: types[0], shown above description */
placeTypeLabel?: string | null;
}
const PhotoCard = ({
@ -83,7 +85,8 @@ const PhotoCard = ({
showAuthor = true,
showActions = true,
showTitle = true,
showDescription = true
showDescription = true,
placeTypeLabel
}: PhotoCardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@ -320,11 +323,14 @@ const PhotoCard = ({
</div>
{variant === 'grid' && (title || description) && (
{variant === 'grid' && (title || description || placeTypeLabel) && (
<div className="px-2 py-1.5 border-t">
{showTitle && title && !isLikelyFilename(title) && (
<h3 className="text-sm font-medium truncate">{title}</h3>
)}
{placeTypeLabel && (
<p className="text-xs text-muted-foreground/90 line-clamp-1 mt-0.5">{placeTypeLabel}</p>
)}
{showDescription && description && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{description}</p>
)}
@ -565,6 +571,10 @@ const PhotoCard = ({
<div className="font-semibold text-sm">{title}</div>
)}
{placeTypeLabel && (
<div className="text-xs text-muted-foreground line-clamp-2">{placeTypeLabel}</div>
)}
{showDescription && description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />

View File

@ -57,7 +57,7 @@ interface MediaGridProps {
categorySlugs?: string[];
categoryIds?: string[];
preset?: CardPreset;
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
columns?: number | 'auto';
@ -356,6 +356,7 @@ const MediaGrid = ({
const getSearchGroup = (item: any): string => {
if (item.type === 'page-vfs-folder') return 'Folders';
if (item._searchSource === 'picture') return 'Pictures';
if (item._searchSource === 'place') return 'Places';
if (item._searchSource === 'file') {
if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures';
return 'Files';
@ -374,7 +375,7 @@ const MediaGrid = ({
groups.get(group)!.push(item);
});
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files'];
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
const sections = [];
for (const group of orderedGroups) {
if (groups.has(group)) {

View File

@ -93,7 +93,7 @@ export const FeedCard: React.FC<FeedCardProps> = ({
};
if (carouselItems.length === 0) {
if (post.type === 'page-vfs-file' || post.type === 'page-vfs-folder' || post.type === 'page-intern' || post.type === 'page-external') {
if (post.type === 'page-vfs-file' || post.type === 'page-vfs-folder' || post.type === 'page-intern' || post.type === 'page-external' || post.type === 'place-search') {
return (
<article className="bg-background md:border md:mb-6 md:pb-0 overflow-hidden p-4">
<div onClick={() => {

View File

@ -17,7 +17,7 @@ interface MobileFeedProps {
sortBy?: FeedSortOption;
categorySlugs?: string[];
categoryIds?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
showTitle?: boolean;
@ -151,6 +151,7 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
const getSearchGroup = (item: any): string => {
if (item.type === 'page-vfs-folder') return 'Folders';
if (item._searchSource === 'picture') return 'Pictures';
if (item._searchSource === 'place') return 'Places';
if (item._searchSource === 'file') {
if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures';
return 'Files';
@ -167,7 +168,7 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
groups.get(group)!.push(post);
}
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files'];
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
const elements: React.ReactNode[] = [];
for (const group of orderedGroups) {

View File

@ -20,7 +20,7 @@ export interface HomeWidgetProps {
headingLevel?: 'h1' | 'h2' | 'h3' | 'h4';
variables?: Record<string, any>;
searchQuery?: string;
initialContentType?: 'posts' | 'pages' | 'pictures' | 'files';
initialContentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
initialVisibilityFilter?: 'invisible' | 'private';
}
import type { FeedSortOption } from '@/hooks/useFeedData';
@ -42,7 +42,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree, FileText, Image as ImageIcon, EyeOff, Lock, SlidersHorizontal, Layers, Camera } from 'lucide-react';
import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree, FileText, Image as ImageIcon, EyeOff, Lock, SlidersHorizontal, Layers, Camera, MapPin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@ -105,7 +105,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
useEffect(() => { setViewMode(propViewMode); }, [propViewMode]);
// Content type filter
const [contentType, setContentType] = useState<'posts' | 'pages' | 'pictures' | 'files' | undefined>(
const [contentType, setContentType] = useState<'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined>(
String(effectiveInitial) === 'all' ? undefined : (effectiveInitial as any)
);
@ -115,7 +115,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
}, [effectiveInitial]);
// Navigate URL when content type changes (only when rendered on a user profile or in search)
const handleContentTypeChange = useCallback((newType: 'posts' | 'pages' | 'pictures' | 'files' | undefined) => {
const handleContentTypeChange = useCallback((newType: 'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined) => {
setContentType(newType);
if (propUserId) {
const basePath = `/user/${propUserId}`;
@ -211,7 +211,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</ToggleGroup>
<ToggleGroup type="single" value={contentType || 'all'} onValueChange={(v) => {
if (!v || v === 'all') handleContentTypeChange(undefined);
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files');
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places');
}}>
<ToggleGroupItem value="all" aria-label="All content" size={size}>
<span className="hidden md:inline"><T>All</T></span>
@ -225,6 +225,12 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
<FileText className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Pages</T></span>
</ToggleGroupItem>
{searchQuery && (
<ToggleGroupItem value="places" aria-label="Places only" size={size}>
<MapPin className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Places</T></span>
</ToggleGroupItem>
)}
{isOwnProfile && (
<ToggleGroupItem value="pictures" aria-label="Pictures only" size={size}>
<Camera className="h-4 w-4 md:mr-2" />
@ -428,7 +434,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
<DropdownMenuLabel className="text-xs"><T>Content</T></DropdownMenuLabel>
<DropdownMenuRadioGroup value={contentType || 'all'} onValueChange={(v) => {
if (v === 'all') handleContentTypeChange(undefined);
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures');
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places');
}}>
<DropdownMenuRadioItem value="all">
<Layers className="h-4 w-4 mr-2" /><T>All</T>
@ -439,6 +445,11 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
<DropdownMenuRadioItem value="pages">
<FileText className="h-4 w-4 mr-2" /><T>Pages</T>
</DropdownMenuRadioItem>
{searchQuery && (
<DropdownMenuRadioItem value="places">
<MapPin className="h-4 w-4 mr-2" /><T>Places</T>
</DropdownMenuRadioItem>
)}
{isOwnProfile && (
<DropdownMenuRadioItem value="pictures">
<Camera className="h-4 w-4 mr-2" /><T>Pictures</T>

View File

@ -22,7 +22,7 @@ interface UseFeedDataProps {
supabaseClient?: any;
categoryIds?: string[];
categorySlugs?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
}

View File

@ -11,7 +11,7 @@ export interface FetchFeedOptions {
sortBy?: FeedSortOption;
categoryIds?: string[];
categorySlugs?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
visibilityFilter?: 'invisible' | 'private';
lang?: string;
}

View File

@ -178,14 +178,14 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | '
if (!cover) {
// Support items without covers that should still be displayed
const allowedWithoutCover = ['page-vfs-folder', 'page-vfs-file', 'page-external', 'page-intern', 'page-github'];
const allowedWithoutCover = ['page-vfs-folder', 'page-vfs-file', 'page-external', 'page-intern', 'page-github', 'place-search'];
if (allowedWithoutCover.includes(post.type)) {
return {
id: post.id,
picture_id: post.id,
title: post.title,
description: post.description,
image_url: post.meta?.url || '',
image_url: post.type === 'place-search' ? '' : (post.meta?.url || ''),
thumbnail_url: null,
type: post.type as MediaType,
meta: post.meta,

View File

@ -3,7 +3,7 @@ const SERVER_API_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
export interface SearchOptions {
q: string;
limit?: number;
type?: 'all' | 'pages' | 'posts' | 'pictures' | 'files';
type?: 'all' | 'pages' | 'posts' | 'pictures' | 'files' | 'places';
sizes?: string;
formats?: string;
token?: string;

View File

@ -26,7 +26,7 @@ const SearchResults = () => {
const columns = columnsParam === 'auto' ? 'auto' : (columnsParam ? parseInt(columnsParam, 10) : 4);
const heading = searchParams.get('heading') || undefined;
const headingLevel = (searchParams.get('headingLevel') as 'h1' | 'h2' | 'h3' | 'h4') || undefined;
const initialContentType = (searchParams.get('type') || searchParams.get('initialContentType')) as 'posts' | 'pages' | 'pictures' | 'files' | undefined;
const initialContentType = (searchParams.get('type') || searchParams.get('initialContentType')) as 'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined;
const visibilityFilter = (searchParams.get('visibilityFilter')) as 'invisible' | 'private' | undefined;
// Sync input with URL query
@ -68,7 +68,7 @@ const SearchResults = () => {
<Input
ref={searchInputRef}
type="search"
placeholder="Search pages, posts, and pictures..."
placeholder="Search pages, posts, pictures, and places..."
className="pl-10 pr-4 h-11 w-full bg-muted/50 border focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
value={inputQuery}
onChange={(e) => setInputQuery(e.target.value)}
@ -79,7 +79,7 @@ const SearchResults = () => {
<div className="text-center py-12">
<Search className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">Enter a search term</h3>
<p className="text-muted-foreground">Search for pages, posts, and pictures</p>
<p className="text-muted-foreground">Search for pages, posts, pictures, and places</p>
</div>
</div>
</div>
@ -87,7 +87,22 @@ const SearchResults = () => {
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen bg-background pt-14">
<div className="container mx-auto px-4 max-w-6xl pb-4">
<form onSubmit={handleSearchSubmit} className="relative max-w-xl mx-auto">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="search"
placeholder="Search pages, posts, pictures, and places..."
className="pl-10 pr-4 h-11 w-full bg-muted/50 border focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
value={inputQuery}
onChange={(e) => setInputQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
/>
</form>
</div>
<HomeWidget
searchQuery={query}
sortBy={sortBy}

View File

@ -38,6 +38,7 @@ export const MEDIA_TYPES = {
PAGE_EXTERNAL: 'page-external',
PAGE_VFS_FILE: 'page-vfs-file',
PAGE_VFS_FOLDER: 'page-vfs-folder',
PLACE_SEARCH: 'place-search',
} as const;
export type MediaType = typeof MEDIA_TYPES[keyof typeof MEDIA_TYPES] | null;