mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Trip members can rate a place 1-5 stars; the place shows the average, with a who-voted custom tooltip and a star-sort toggle in the places sidebar. Saved collection places are ratable too (plus a min-rating filter), and ratings travel with a place copied between a trip and a collection — carrying only the votes of users who belong to both sides. Adds a full Collections MCP surface (list/get, CRUD lists + places, status, rate_collection_place, labels, sharing, copy-to-trip) under a new collections:read/collections:write scope, plus rate_place for trip places; rating aggregates flow through the existing place outputs. Mobile trip + collection place sheets gain the rating row. New UI strings translated across all 22 locales.
This commit is contained in:
@@ -404,6 +404,10 @@ export const placesApi = {
|
||||
fd.append('image', file)
|
||||
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
|
||||
},
|
||||
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
|
||||
rating === null
|
||||
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
|
||||
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
|
||||
@@ -74,6 +74,10 @@ export const collectionsApi = {
|
||||
postMultipart(`${base}/places/${pid}/image`, formData),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
|
||||
rating === null
|
||||
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
|
||||
: ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data),
|
||||
deletePlace: (pid: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
|
||||
deleteMany: (ids: number[]): Promise<unknown> =>
|
||||
|
||||
@@ -7,6 +7,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
const expected = [
|
||||
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||
'places:read', 'places:write',
|
||||
'collections:read', 'collections:write',
|
||||
'atlas:read', 'atlas:write',
|
||||
'packing:read', 'packing:write',
|
||||
'todos:read', 'todos:write',
|
||||
@@ -16,6 +17,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -32,8 +34,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(29)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
|
||||
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
|
||||
'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' },
|
||||
'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' },
|
||||
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
|
||||
|
||||
@@ -27,8 +27,10 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
ratingFilter: 'all',
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
onRatingFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
@@ -49,9 +51,9 @@ beforeEach(() => {
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Both dropdown triggers currently read "All" (status=all, category=all).
|
||||
// With a category present there are exactly two "All" triggers: status + category.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
// Three dropdown triggers read "All" (status=all, category=all, rating=all):
|
||||
// status + category + the #1435 rating filter.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
|
||||
@@ -84,15 +86,15 @@ describe('CollectionFilterBar', () => {
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Two dropdown triggers = status + category.
|
||||
// Three dropdown triggers = status + category + rating.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(triggers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// Only the status dropdown remains.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
|
||||
// The status + rating dropdowns remain (the rating filter always shows).
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -67,8 +67,10 @@ interface CollectionFilterBarProps {
|
||||
counts: Record<StatusFilter, number>
|
||||
categoryFilter: number | 'all'
|
||||
categoryOptions: CategoryOption[]
|
||||
ratingFilter: number | 'all'
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
onRatingFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
@@ -88,7 +90,7 @@ interface CollectionFilterBarProps {
|
||||
* Custom compact dropdowns so they barely take any space.
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
statusFilter, counts, categoryFilter, categoryOptions, ratingFilter, onStatusFilter, onCategoryFilter, onRatingFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
@@ -109,12 +111,23 @@ export default function CollectionFilterBar({
|
||||
}),
|
||||
]
|
||||
|
||||
// Minimum-average-rating filter (#1435): All, then ≥5…≥1 stars.
|
||||
const ratingOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all') },
|
||||
...[5, 4, 3, 2, 1].map(n => ({
|
||||
key: n,
|
||||
label: `${n}+`,
|
||||
icon: <Star size={13} color="#facc15" fill="#facc15" />,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="col-filterbar">
|
||||
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
|
||||
{categoryOptions.length > 0 && (
|
||||
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
|
||||
)}
|
||||
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
|
||||
{showSelect && (
|
||||
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
@@ -35,6 +36,8 @@ interface CollectionPlaceDetailProps {
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
/** Cast/clear the current user's star vote (#1435); every member may vote. */
|
||||
onRate?: (rating: number | null) => Promise<void> | void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -208,6 +211,13 @@ export default function CollectionPlaceDetail({
|
||||
{/* Status — live for editors, read-only for viewers */}
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
||||
{onRate && (
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
{/* Category */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import remarkBreaks from 'remark-breaks'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import StatusBadge from '../Collections/StatusBadge'
|
||||
import { mapsApi, pluginsApi } from '../../api/client'
|
||||
@@ -131,6 +132,8 @@ interface PlaceInspectorProps {
|
||||
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
|
||||
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
|
||||
onUploadImage?: (placeId: number, file: File) => Promise<void>
|
||||
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
|
||||
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
// ── Collection-mode props ──
|
||||
@@ -144,7 +147,7 @@ export default function PlaceInspector({
|
||||
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
|
||||
assignments = {}, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
|
||||
}: PlaceInspectorProps) {
|
||||
@@ -341,6 +344,17 @@ export default function PlaceInspector({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collaborative rating (#1435) — every member's own vote, shown as the average. */}
|
||||
{mode === 'trip' && onRate && (
|
||||
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '8px 12px' }}>
|
||||
<PlaceRating
|
||||
ratings={place.ratings ?? []}
|
||||
ratingAvg={place.rating_avg}
|
||||
onRate={rating => onRate(place.id, rating)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telefon */}
|
||||
{(place.phone || googleDetails?.phone) && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, Plus, X, Upload, ChevronDown, Check, MapPin } from 'lucide-react'
|
||||
import { Search, Plus, X, Upload, ChevronDown, Check, MapPin, Star } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
@@ -25,6 +25,7 @@ export function PlacesHeader(S: SidebarState) {
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
|
||||
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
|
||||
ratingSort, setRatingSort,
|
||||
} = S
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
|
||||
@@ -165,6 +166,25 @@ export function PlacesHeader(S: SidebarState) {
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} className="text-content-faint" style={{ flexShrink: 0, transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{/* Star sort (#1435): order the list by average rating, best first. */}
|
||||
<Tooltip label={t('places.sortByRating')} placement="bottom">
|
||||
<button
|
||||
onClick={() => setRatingSort(v => !v)}
|
||||
aria-label={t('places.sortByRating')}
|
||||
aria-pressed={ratingSort}
|
||||
className={ratingSort ? 'text-accent' : 'text-content-faint'}
|
||||
style={{
|
||||
width: 30, flexShrink: 0, borderRadius: 8,
|
||||
border: `1px solid ${ratingSort ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: ratingSort ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||
}}
|
||||
>
|
||||
<Star size={13} strokeWidth={2.2} fill={ratingSort ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canEditPlaces && (
|
||||
<Tooltip label={t('common.select')} placement="bottom">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Plus, Check, Route } from 'lucide-react'
|
||||
import { Plus, Check, Route, Star } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import type { Place, Category } from '../../types'
|
||||
@@ -89,6 +89,13 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
{/* Average member rating (#1435). */}
|
||||
{(place.rating_count ?? 0) > 0 && place.rating_avg != null && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-secondary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<Star size={9} color="#facc15" fill="#facc15" />
|
||||
{(Math.round(place.rating_avg * 10) / 10).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
|
||||
@@ -155,6 +155,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
|
||||
const [ratingSort, setRatingSort] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
@@ -196,18 +198,26 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
const filtered = useMemo(() => {
|
||||
const list = places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
// Star sort (#1435): highest average first, unrated places at the end;
|
||||
// ties keep the list's original order (stable sort).
|
||||
if (ratingSort) {
|
||||
return [...list].sort((a, b) => (b.rating_avg ?? -1) - (a.rating_avg ?? -1))
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [places, filter, categoryFilters, search, plannedIds])
|
||||
return list
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingSort])
|
||||
|
||||
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@@ -267,6 +277,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters,
|
||||
ratingSort, setRatingSort,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Star } from 'lucide-react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { PlaceRatingVote } from '@trek/shared'
|
||||
|
||||
const STAR_FILL = '#facc15'
|
||||
|
||||
interface StarsProps {
|
||||
/** Value the stars visualize (average for display, own vote while hovering). */
|
||||
value: number
|
||||
size?: number
|
||||
/** Interactive mode: hover previews, click casts/clears the own vote. */
|
||||
onRate?: (rating: number | null) => void
|
||||
myRating?: number | null
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
/** Five stars with fractional fill; interactive when onRate is set (#1435).
|
||||
* Clicking the star matching the own vote clears it. */
|
||||
export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }: StarsProps) {
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const shown = hover ?? value
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onRate ? 'radiogroup' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map(i => {
|
||||
// Fractional fill for averages: full below, empty above, clipped between.
|
||||
const fill = Math.max(0, Math.min(1, shown - (i - 1)))
|
||||
const star = (
|
||||
<span key={i} style={{ position: 'relative', display: 'inline-flex', width: size, height: size }}>
|
||||
<Star size={size} color={STAR_FILL} fill="none" style={{ position: 'absolute', inset: 0 }} />
|
||||
{fill > 0 && (
|
||||
<span style={{ position: 'absolute', inset: 0, width: `${fill * 100}%`, overflow: 'hidden' }}>
|
||||
<Star size={size} color={STAR_FILL} fill={STAR_FILL} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
if (!onRate) return star
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={myRating === i}
|
||||
aria-label={`${i}`}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onFocus={() => setHover(i)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => onRate(myRating === i ? null : i)}
|
||||
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex' }}
|
||||
>
|
||||
{star}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlaceRatingProps {
|
||||
ratings: PlaceRatingVote[]
|
||||
ratingAvg: number | null | undefined
|
||||
onRate?: (rating: number | null) => void
|
||||
size?: number
|
||||
/** Hide the voter avatar strip (compact mobile rows). */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The collaborative rating row (#1435): interactive stars (hover previews the
|
||||
* own vote, the average fill shows otherwise), the numeric average, and the
|
||||
* voter avatars. Hovering reveals a custom tooltip listing every voter with
|
||||
* their stars.
|
||||
*/
|
||||
export default function PlaceRating({ ratings, ratingAvg, onRate, size = 16, compact = false }: PlaceRatingProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentUserId = useAuthStore(s => s.user?.id)
|
||||
const [tip, setTip] = useState<{ top: number; left: number } | null>(null)
|
||||
const myRating = ratings.find(r => r.user_id === currentUserId)?.rating ?? null
|
||||
const avg = ratingAvg ?? null
|
||||
|
||||
const showTip = (e: React.MouseEvent) => {
|
||||
if (ratings.length === 0) return
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
setTip({ top: r.top, left: r.left + r.width / 2 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: size + 4 }}
|
||||
onMouseEnter={showTip}
|
||||
onMouseLeave={() => setTip(null)}
|
||||
>
|
||||
<Stars value={avg ?? 0} size={size} onRate={onRate} myRating={myRating} ariaLabel={t('places.yourRating')} />
|
||||
{avg !== null ? (
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{(Math.round(avg * 10) / 10).toLocaleString()} <span style={{ fontWeight: 400, color: 'var(--text-faint)' }}>({ratings.length})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('places.notRated')}</span>
|
||||
)}
|
||||
|
||||
{/* Who voted — avatar strip. */}
|
||||
{!compact && ratings.length > 0 && (
|
||||
<span style={{ display: 'inline-flex', marginLeft: 'auto' }}>
|
||||
{ratings.slice(0, 6).map((r, i) => (
|
||||
<span key={r.user_id} className="bg-surface-tertiary text-content-muted" style={{
|
||||
width: 18, height: 18, borderRadius: '50%', overflow: 'hidden', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
|
||||
marginLeft: i ? -5 : 0, border: '1.5px solid var(--bg-elevated, #fff)',
|
||||
}}>
|
||||
{r.avatar ? <img src={avatarSrc(r.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : r.username?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Custom voter tooltip — fixed at the root so nothing clips it. */}
|
||||
{tip && ratings.length > 0 && ReactDOM.createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="bg-surface-card text-content border border-edge-faint"
|
||||
style={{
|
||||
position: 'fixed', top: tip.top - 8, left: tip.left, transform: 'translate(-50%, -100%)',
|
||||
zIndex: 100000, pointerEvents: 'none', borderRadius: 10, padding: '8px 11px', minWidth: 140,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.15)', fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
>
|
||||
{ratings.map(r => (
|
||||
<div key={r.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.username}{r.user_id === currentUserId ? ` (${t('vacay.you')})` : ''}
|
||||
</span>
|
||||
<Stars value={r.rating} size={11} />
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { normalizeImageFile } from '../../../utils/convertHeic'
|
||||
import { getApiErrorMessage } from '../../../types'
|
||||
import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import PlaceRating from '../../../components/shared/StarRating'
|
||||
import MCollCategoryPicker from './MCollCategoryPicker'
|
||||
import MCollLinksEditor from './MCollLinksEditor'
|
||||
import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
@@ -38,6 +39,8 @@ interface MCollPlaceSheetProps {
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
/** Cast/clear the current user's star vote (#1435); every member may vote. */
|
||||
onRate?: (rating: number | null) => Promise<void> | void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ interface MCollPlaceSheetProps {
|
||||
* description / links.
|
||||
*/
|
||||
export default function MCollPlaceSheet({
|
||||
place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
|
||||
}: MCollPlaceSheetProps) {
|
||||
const toast = useToast()
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -231,6 +234,13 @@ export default function MCollPlaceSheet({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */}
|
||||
{onRate && (
|
||||
<div className="mt-3">
|
||||
<PlaceRating ratings={held.ratings ?? []} ratingAvg={held.rating_avg} onRate={onRate} size={18} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-4">{t('common.name').toUpperCase()}</Eyebrow>
|
||||
|
||||
@@ -84,13 +84,14 @@ export default function MCollections() {
|
||||
setLabelDrop(false)
|
||||
}, [c.activeId])
|
||||
|
||||
// The mobile toolbar has no category filter control, but the shared hook
|
||||
// still applies one set on desktop — keep it cleared so resizing to phone
|
||||
// doesn't silently filter the list and map.
|
||||
const { categoryFilter, setCategoryFilter } = c
|
||||
// The mobile toolbar has no category or rating filter control, but the shared
|
||||
// hook still applies whatever was set on desktop — keep both cleared so
|
||||
// resizing to phone doesn't silently filter the list and map (#1435).
|
||||
const { categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter } = c
|
||||
useEffect(() => {
|
||||
if (categoryFilter !== 'all') setCategoryFilter('all')
|
||||
}, [categoryFilter, setCategoryFilter])
|
||||
if (ratingFilter !== 'all') setRatingFilter('all')
|
||||
}, [categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter])
|
||||
|
||||
const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title'))
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
@@ -456,6 +457,7 @@ export default function MCollections() {
|
||||
onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)}
|
||||
onCopyToTrip={c.openCopyForSelectedPlace}
|
||||
onRemove={c.handleDetailRemove}
|
||||
onRate={r => c.handleRatePlace(c.selectedPlace!.id, r)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore'
|
||||
import { collectionTargetFromPlace } from '../lib/collectionTarget'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import PlaceRating from '../../../../components/shared/StarRating'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps'
|
||||
@@ -168,6 +169,16 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
openSavePicker(collectionTargetFromPlace(place))
|
||||
}
|
||||
|
||||
// Collaborative rating (#1435): every trip member casts their own star vote.
|
||||
const handleRate = async (rating: number | null) => {
|
||||
if (!place) return
|
||||
try {
|
||||
await planner.tripActions.ratePlace(planner.tripId, place.id, rating)
|
||||
} catch (err: unknown) {
|
||||
planner.toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}
|
||||
|
||||
const showOnMap = () => {
|
||||
close()
|
||||
if (shell.trTab !== 'plan') shell.setTrTab('plan')
|
||||
@@ -259,6 +270,11 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */}
|
||||
<div className={`mt-[10px] rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={handleRate} size={18} />
|
||||
</div>
|
||||
|
||||
{place.description && (
|
||||
<div className={`mt-[10px] rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
<div className="font-geist text-[0.75rem] leading-[1.5] text-m-muted">{place.description}</div>
|
||||
|
||||
@@ -102,8 +102,10 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
counts={c.counts}
|
||||
categoryFilter={c.categoryFilter}
|
||||
categoryOptions={c.categoryOptions}
|
||||
ratingFilter={c.ratingFilter}
|
||||
onStatusFilter={c.setStatusFilter}
|
||||
onCategoryFilter={c.setCategoryFilter}
|
||||
onRatingFilter={c.setRatingFilter}
|
||||
showLabels={isRealList}
|
||||
labelOptions={c.labelOptions}
|
||||
labelFilter={c.labelFilter}
|
||||
@@ -298,6 +300,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)}
|
||||
onCopyToTrip={c.openCopyForSelectedPlace}
|
||||
onRemove={c.handleDetailRemove}
|
||||
onRate={r => c.handleRatePlace(c.selectedPlace!.id, r)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -573,6 +573,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
/>
|
||||
@@ -612,6 +613,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
leftWidth={0}
|
||||
rightWidth={0}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface PlaceLike {
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
label_ids?: number[];
|
||||
rating_avg?: number | null;
|
||||
}
|
||||
function cp(overrides: PlaceLike): CollectionPlace {
|
||||
return {
|
||||
@@ -80,6 +81,25 @@ describe('collectionsModel', () => {
|
||||
expect(out.map(p => p.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-036: rating filter keeps places whose average is >= the floor (#1435)', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', rating_avg: 4.5 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', rating_avg: 3 }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', rating_avg: null }), // unrated → excluded by a floor
|
||||
cp({ id: 4, name: 'D', status: 'idea' }), // unrated (undefined) → excluded
|
||||
];
|
||||
const out = filterPlaces(places, 'all', '', 'all', [], 4);
|
||||
expect(out.map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("FE-COLLECTIONS-MODEL-037: rating filter 'all' keeps unrated places too", () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', rating_avg: 5 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', rating_avg: null }),
|
||||
];
|
||||
expect(filterPlaces(places, 'all', '', 'all', [], 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-004: search matches place name (case-insensitive)', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Eiffel Tower', status: 'idea' }),
|
||||
|
||||
@@ -54,12 +54,15 @@ export function filterPlaces(
|
||||
search: string,
|
||||
categoryFilter: number | 'all' = 'all',
|
||||
labelFilter: number[] = [],
|
||||
ratingFilter: number | 'all' = 'all',
|
||||
): CollectionPlace[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
return places.filter(p => {
|
||||
if (!p) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
|
||||
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
|
||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
|
||||
@@ -50,15 +50,15 @@ export function useCollections() {
|
||||
const store = useCollectionStore()
|
||||
const {
|
||||
collections, activeId, places, members, labels, incomingInvites,
|
||||
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
view, statusFilter, categoryFilter, ratingFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
loading, placesLoading,
|
||||
loadAll, setActive, refreshActive, loadCollection,
|
||||
deleteCollection,
|
||||
setStatus, updatePlace, uploadPlaceImage, deletePlace, deleteMany, copyToTrip, clearSelection,
|
||||
setStatus, updatePlace, uploadPlaceImage, ratePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
|
||||
moveToList, duplicateToList, setSelectedIds,
|
||||
createLabel, updateLabel, deleteLabel, assignLabels,
|
||||
acceptInvite, declineInvite,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setRatingFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
} = store
|
||||
|
||||
// ── Page-local UI state ─────────────────────────────────────────────
|
||||
@@ -167,8 +167,8 @@ export function useCollections() {
|
||||
|
||||
// Labels are per-collection, so never apply them on the "All saved" union.
|
||||
const visiblePlaces = useMemo(
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
|
||||
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter, ratingFilter)),
|
||||
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter, ratingFilter],
|
||||
)
|
||||
// Categories actually present in this list, for the category filter dropdown.
|
||||
const categoryOptions = useMemo(() => presentCategories(places), [places])
|
||||
@@ -221,6 +221,14 @@ export function useCollections() {
|
||||
}
|
||||
}, [deletePlace, toast, t])
|
||||
|
||||
const handleRatePlace = useCallback(async (placeId: number, rating: number | null) => {
|
||||
try {
|
||||
await ratePlace(placeId, rating)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}, [ratePlace, toast, t])
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (selectedIds.length === 0) return
|
||||
try {
|
||||
@@ -378,11 +386,11 @@ export function useCollections() {
|
||||
myRole, canEdit, canDelete,
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
|
||||
view, statusFilter, categoryFilter, categoryOptions, ratingFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
labels, labelFilter, labelOptions,
|
||||
loading, placesLoading,
|
||||
// store setters
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setRatingFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
updatePlace, uploadPlaceImage,
|
||||
// labels
|
||||
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
|
||||
@@ -399,7 +407,7 @@ export function useCollections() {
|
||||
copyIds, openCopyForSelectedPlace, openCopyForSelection, closeCopy, handleCopyToTrip,
|
||||
// handlers
|
||||
handleSelectList, handleDeleteList,
|
||||
handleStatusChange, handleDeletePlace, handleDeleteSelected,
|
||||
handleStatusChange, handleRatePlace, handleDeletePlace, handleDeleteSelected,
|
||||
handleAcceptInvite, handleDeclineInvite,
|
||||
allVisibleSelected, handleSelectAll,
|
||||
listPickerMode, setListPickerMode, handleMoveToList, handleDuplicateToList,
|
||||
|
||||
@@ -34,6 +34,7 @@ interface CollectionState {
|
||||
view: CollectionView
|
||||
statusFilter: StatusFilter
|
||||
categoryFilter: number | 'all'
|
||||
ratingFilter: number | 'all'
|
||||
labelFilter: number[]
|
||||
search: string
|
||||
selectedPlaceId: number | null
|
||||
@@ -56,6 +57,7 @@ interface CollectionState {
|
||||
setStatus: (placeId: number, status: CollectionStatus) => Promise<void>
|
||||
updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise<void>
|
||||
uploadPlaceImage: (placeId: number, file: File) => Promise<void>
|
||||
ratePlace: (placeId: number, rating: number | null) => Promise<void>
|
||||
deletePlace: (placeId: number) => Promise<void>
|
||||
deleteMany: (ids: number[]) => Promise<void>
|
||||
copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
|
||||
@@ -78,6 +80,7 @@ interface CollectionState {
|
||||
setView: (view: CollectionView) => void
|
||||
setStatusFilter: (filter: StatusFilter) => void
|
||||
setCategoryFilter: (filter: number | 'all') => void
|
||||
setRatingFilter: (filter: number | 'all') => void
|
||||
setLabelFilter: (labelIds: number[]) => void
|
||||
setSearch: (search: string) => void
|
||||
setSelectedPlaceId: (id: number | null) => void
|
||||
@@ -97,6 +100,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
ratingFilter: 'all',
|
||||
labelFilter: [],
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
@@ -238,6 +242,11 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
|
||||
},
|
||||
|
||||
ratePlace: async (placeId: number, rating: number | null) => {
|
||||
const updated = await collectionsApi.ratePlace(placeId, rating)
|
||||
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
|
||||
},
|
||||
|
||||
deletePlace: async (placeId: number) => {
|
||||
set({ places: get().places.filter(p => p.id !== placeId) })
|
||||
await collectionsApi.deletePlace(placeId)
|
||||
@@ -383,6 +392,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
setView: (view: CollectionView) => set({ view }),
|
||||
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
|
||||
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
|
||||
setRatingFilter: (filter: number | 'all') => set({ ratingFilter: filter }),
|
||||
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
|
||||
setSearch: (search: string) => set({ search }),
|
||||
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PlacesSlice {
|
||||
addPlace: (tripId: number | string, placeData: Partial<Place> & { name: string }) => Promise<Place>
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise<Place>
|
||||
ratePlace: (tripId: number | string, placeId: number, rating: number | null) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
|
||||
@@ -81,6 +82,18 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
}
|
||||
},
|
||||
|
||||
ratePlace: async (tripId, placeId, rating) => {
|
||||
// Casts (or clears, rating null) the current user's own star vote (#1435)
|
||||
// and applies the returned place with the fresh average.
|
||||
try {
|
||||
const data = await placesApi.rate(tripId, placeId, rating)
|
||||
applyUpdatedPlace(set, placeId, data.place)
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error rating place'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placeRepo.delete(tripId, placeId)
|
||||
|
||||
@@ -96,6 +96,9 @@ interface PlaceWithCategory extends Place {
|
||||
interface PlaceWithTags extends Place {
|
||||
category: { id: number; name: string; color: string; icon: string } | null;
|
||||
tags: Tag[];
|
||||
ratings: { user_id: number; username: string; avatar: string | null; rating: number }[];
|
||||
rating_avg: number | null;
|
||||
rating_count: number;
|
||||
}
|
||||
|
||||
function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
|
||||
@@ -114,6 +117,14 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
|
||||
WHERE pt.place_id = ?
|
||||
`).all(placeId) as Tag[];
|
||||
|
||||
// Collaborative ratings (#1435): every voter with username/avatar for the
|
||||
// who-voted tooltip; the displayed value is the average.
|
||||
const ratings = db.prepare(`
|
||||
SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr
|
||||
JOIN users u ON pr.user_id = u.id
|
||||
WHERE pr.place_id = ? ORDER BY pr.created_at
|
||||
`).all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[];
|
||||
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id ? {
|
||||
@@ -123,6 +134,9 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
|
||||
icon: place.category_icon!,
|
||||
} : null,
|
||||
tags,
|
||||
ratings,
|
||||
rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null,
|
||||
rating_count: ratings.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3727,6 +3727,33 @@ function runMigrations(db: Database.Database): void {
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id);');
|
||||
},
|
||||
// Collaborative place ratings (#1435): every trip member can rate a trip
|
||||
// place 1-5, every collection member a saved place; the displayed value is
|
||||
// the average. One row per user and place, mirroring collab_poll_votes.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(place_id, user_id)
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id);');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collection_place_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_place_id, user_id)
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id);');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -155,6 +155,15 @@ function createTables(db: Database.Database): void {
|
||||
PRIMARY KEY (place_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(place_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
@@ -425,6 +434,15 @@ function createTables(db: Database.Database): void {
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_place_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_place_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_place_tags (
|
||||
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -9,6 +9,8 @@ export const SCOPES = {
|
||||
TRIPS_SHARE: 'trips:share',
|
||||
PLACES_READ: 'places:read',
|
||||
PLACES_WRITE: 'places:write',
|
||||
COLLECTIONS_READ: 'collections:read',
|
||||
COLLECTIONS_WRITE: 'collections:write',
|
||||
ATLAS_READ: 'atlas:read',
|
||||
ATLAS_WRITE: 'atlas:write',
|
||||
PACKING_READ: 'packing:read',
|
||||
@@ -49,6 +51,8 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
|
||||
'trips:share': { label: 'Manage share links', description: 'Create, update, and revoke public share links for trips', group: 'Trips' },
|
||||
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, and categories', group: 'Places' },
|
||||
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, and tags', group: 'Places' },
|
||||
'collections:read': { label: 'View collections', description: 'Read saved-place collections, their places, ratings, labels, and members', group: 'Collections' },
|
||||
'collections:write': { label: 'Manage collections', description: 'Create/edit collections, save, rate, label and copy places, and share lists', group: 'Collections' },
|
||||
'atlas:read': { label: 'View Atlas', description: 'Read visited countries, regions, and bucket list', group: 'Atlas' },
|
||||
'atlas:write': { label: 'Manage Atlas', description: 'Mark countries and regions visited, manage bucket list', group: 'Atlas' },
|
||||
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
import { registerPlaceTools } from './tools/places';
|
||||
import { registerCollectionTools } from './tools/collections';
|
||||
import { registerDayTools } from './tools/days';
|
||||
import { registerBudgetTools } from './tools/budget';
|
||||
import { registerPackingTools } from './tools/packing';
|
||||
@@ -23,6 +24,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerPlaceTools(server, userId, scopes);
|
||||
|
||||
registerCollectionTools(server, userId, scopes);
|
||||
|
||||
registerBudgetTools(server, userId, scopes);
|
||||
|
||||
registerPackingTools(server, userId, scopes);
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import * as collections from '../../services/collectionsService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import {
|
||||
collectionCreateRequestSchema, collectionUpdateRequestSchema,
|
||||
collectionSavePlaceRequestSchema, collectionPlaceUpdateRequestSchema,
|
||||
collectionCopyToTripRequestSchema, collectionLabelCreateRequestSchema,
|
||||
collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema,
|
||||
collectionInviteRequestSchema, COLLECTION_STATUSES, COLLECTION_ROLES,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Convert a thrown service error (httpError carries `.message`) into MCP error text. */
|
||||
function errText(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return 'Operation failed';
|
||||
}
|
||||
function fail(err: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: errText(err) }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collections MCP surface (#1435) — the full set of actions a member can take on
|
||||
* a saved-places list: browse, CRUD lists + places, status, collaborative star
|
||||
* ratings, per-list labels, sharing, and copy-into-a-trip. Every service call
|
||||
* enforces the caller's membership/role, so the tools stay thin wrappers. Place
|
||||
* outputs carry the `ratings`/`rating_avg`/`rating_count` aggregate.
|
||||
*/
|
||||
export function registerCollectionTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'collections');
|
||||
const W = canWrite(scopes, 'collections');
|
||||
|
||||
const denyDemo = () => (isDemoUser(userId) ? demoDenied() : null);
|
||||
|
||||
// ── Read ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_collections',
|
||||
{
|
||||
description: 'List all saved-place collections (lists) the user owns or has accepted a share for, plus any pending incoming invites. Use get_collection for a list\'s places.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
try { return ok(collections.listCollections(userId)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_collection',
|
||||
{
|
||||
description: 'Get one collection with its members, labels, and all saved places. Each place includes rating_avg / rating_count and the per-member ratings (#1435) so you can plan around highly-rated spots.',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
try { return ok(collections.getCollection(userId, collectionId)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'available_collection_users',
|
||||
{
|
||||
description: 'List users who can still be invited to a collection (excludes current members and guests). Use the returned ids with invite_to_collection.',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
try {
|
||||
// Owner-only, mirroring the REST gate — availableUsers() itself does no
|
||||
// access check, so without this any token could enumerate the user list.
|
||||
collections.assertAccess(userId, collectionId);
|
||||
if (!collections.isOwner(userId, collectionId)) {
|
||||
return { content: [{ type: 'text' as const, text: 'Only the collection owner can view invitable users.' }], isError: true };
|
||||
}
|
||||
return ok({ users: collections.availableUsers(userId, collectionId) });
|
||||
} catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// ── Collections CRUD ─────────────────────────────────────────────────
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_collection',
|
||||
{
|
||||
description: 'Create a new saved-place collection (list) owned by the user.',
|
||||
inputSchema: collectionCreateRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async (body) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ collection: collections.createCollection(userId, body) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_collection',
|
||||
{
|
||||
description: 'Update a collection\'s name, description, colour, icon, cover, links, or sort order. Owner/admin only.',
|
||||
inputSchema: { collectionId: z.number().int().positive(), ...collectionUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId, ...body }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ collection: collections.updateCollection(userId, collectionId, body) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_collection',
|
||||
{
|
||||
description: 'Permanently delete a collection and all its saved places. Owner only. This cannot be undone.',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.deleteCollection(userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'reorder_collections',
|
||||
{
|
||||
description: 'Reorder the user\'s collections. Pass every collection id in the desired order.',
|
||||
inputSchema: { orderedIds: z.array(z.number().int().positive()).min(1) },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ orderedIds }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.reorderCollections(userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// ── Places ────────────────────────────────────────────────────────────
|
||||
|
||||
if (W) server.registerTool(
|
||||
'save_place_to_collection',
|
||||
{
|
||||
description: 'Save a place into a collection from a raw payload (name required; set google_place_id/osm_id from search_place for rich details). Returns a duplicate marker instead of saving when a similar place already exists, unless force is true.',
|
||||
inputSchema: collectionSavePlaceRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async (body) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok(collections.savePlace(userId, body)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'save_trip_places_to_collection',
|
||||
{
|
||||
description: 'Copy one or more existing trip places into a collection (the server reads each place). Their star ratings (#1435) travel along for members shared on both. Duplicates are skipped unless force is true.',
|
||||
inputSchema: {
|
||||
collectionId: z.number().int().positive(),
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(1000),
|
||||
force: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ collectionId, tripId, placeIds, force }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok(collections.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_collection_place',
|
||||
{
|
||||
description: 'Update a saved place\'s name, description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).',
|
||||
inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ placeId, ...body }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ place: collections.updatePlace(userId, placeId, body) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_collection_place_status',
|
||||
{
|
||||
description: 'Set a saved place\'s status: idea, want, or visited.',
|
||||
inputSchema: { placeId: z.number().int().positive(), status: z.enum(COLLECTION_STATUSES) },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ placeId, status }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ place: collections.setStatus(userId, placeId, status) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'rate_collection_place',
|
||||
{
|
||||
description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.",
|
||||
inputSchema: {
|
||||
placeId: z.number().int().positive(),
|
||||
rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ placeId, rating }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ place: collections.setRating(userId, placeId, rating ?? null) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_collection_place',
|
||||
{
|
||||
description: 'Remove a saved place from its collection. Requires delete permission on the list.',
|
||||
inputSchema: { placeId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ placeId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.deletePlace(userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'copy_collection_places_to_trip',
|
||||
{
|
||||
description: 'Copy one or more saved collection places into a trip (dedup precheck on the server). Ratings (#1435) travel into the trip; trip members keep voting there. Requires edit access to the target trip.',
|
||||
inputSchema: collectionCopyToTripRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async (body) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok(collections.copyToTrip(userId, body)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// ── Labels ────────────────────────────────────────────────────────────
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_collection_label',
|
||||
{
|
||||
description: 'Create a custom per-collection label (name + optional hex colour) for grouping/filtering places.',
|
||||
inputSchema: collectionLabelCreateRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ collection_id, name, color }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ label: collections.createLabel(userId, collection_id, name, color) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_collection_label',
|
||||
{
|
||||
description: 'Rename or recolour a collection label, or change its sort order.',
|
||||
inputSchema: { labelId: z.number().int().positive(), ...collectionLabelUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ labelId, ...body }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok({ label: collections.updateLabel(userId, labelId, body) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_collection_label',
|
||||
{
|
||||
description: 'Delete a collection label; its assignments on places are cleared.',
|
||||
inputSchema: { labelId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ labelId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.deleteLabel(userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'assign_collection_labels',
|
||||
{
|
||||
description: 'Add (or with remove=true, take away) one or more labels across a set of saved places. Only labels belonging to each place\'s own list are applied.',
|
||||
inputSchema: { ...collectionLabelAssignRequestSchema.shape, remove: z.boolean().optional() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ label_ids, place_ids, remove }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { return ok(collections.assignLabels(userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// ── Sharing ───────────────────────────────────────────────────────────
|
||||
|
||||
if (W) server.registerTool(
|
||||
'invite_to_collection',
|
||||
{
|
||||
description: 'Invite a user (by id, from available_collection_users) to collaborate on a collection, with a role of viewer, editor, or admin (default editor). Owner only.',
|
||||
inputSchema: collectionInviteRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ collection_id, user_id, role }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
const me = db.prepare('SELECT username, email FROM users WHERE id = ?').get(userId) as { username: string; email: string } | undefined;
|
||||
const res = collections.sendInvite(collection_id, userId, me?.username ?? '', me?.email ?? '', user_id, role);
|
||||
if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_collection_member_role',
|
||||
{
|
||||
description: 'Change an accepted member\'s permission role (viewer, editor, or admin). Owner only.',
|
||||
inputSchema: {
|
||||
collectionId: z.number().int().positive(),
|
||||
userId: z.number().int().positive(),
|
||||
role: z.enum(COLLECTION_ROLES),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId, userId: targetUserId, role }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.setMemberRole(userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'remove_collection_member',
|
||||
{
|
||||
description: 'Remove an accepted member from a shared collection (a kick). Owner only.',
|
||||
inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ collectionId, userId: targetUserId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.removeMember(userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'cancel_collection_invite',
|
||||
{
|
||||
description: 'Cancel a pending invite you sent to a user for a collection. Owner only.',
|
||||
inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId, userId: targetUserId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.cancelInvite(collectionId, userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'accept_collection_invite',
|
||||
{
|
||||
description: 'Accept a pending invite to join a shared collection.',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
const res = collections.acceptInvite(userId, collectionId, undefined);
|
||||
if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'decline_collection_invite',
|
||||
{
|
||||
description: 'Decline a pending invite to a shared collection.',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.declineInvite(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'leave_collection',
|
||||
{
|
||||
description: 'Leave a shared collection you are a member of. The owner cannot leave (delete the list instead).',
|
||||
inputSchema: { collectionId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ collectionId }) => {
|
||||
const demo = denyDemo(); if (demo) return demo;
|
||||
try { collections.leaveCollection(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
@@ -139,6 +139,28 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'rate_place',
|
||||
{
|
||||
description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeId, rating }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
// Rating is a personal vote — any trip member may cast one, place_edit not required.
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = ratePlace(String(tripId), String(placeId), userId, rating ?? null);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_place',
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
collectionSaveFromTripManyRequestSchema,
|
||||
collectionPlaceUpdateRequestSchema,
|
||||
collectionSetStatusRequestSchema,
|
||||
placeRatingRequestSchema,
|
||||
collectionCopyToTripRequestSchema,
|
||||
collectionInviteRequestSchema,
|
||||
collectionInviteActionRequestSchema,
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
type CollectionSaveFromTripManyRequest,
|
||||
type CollectionPlaceUpdateRequest,
|
||||
type CollectionSetStatusRequest,
|
||||
type PlaceRatingRequest,
|
||||
type CollectionCopyToTripRequest,
|
||||
type CollectionInviteRequest,
|
||||
type CollectionInviteActionRequest,
|
||||
@@ -168,6 +171,21 @@ export class CollectionsController {
|
||||
return this.collections.setStatus(user.id, Number(pid), body.status, socketId);
|
||||
}
|
||||
|
||||
@Put('places/:pid/rating')
|
||||
setRating(
|
||||
@CurrentUser() user: User,
|
||||
@Param('pid') pid: string,
|
||||
@Body(new ZodValidationPipe(placeRatingRequestSchema)) body: PlaceRatingRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.collections.setRating(user.id, Number(pid), body.rating, socketId);
|
||||
}
|
||||
|
||||
@Delete('places/:pid/rating')
|
||||
clearRating(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.collections.setRating(user.id, Number(pid), null, socketId);
|
||||
}
|
||||
|
||||
@Post('places/:pid/image')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD))
|
||||
|
||||
@@ -33,6 +33,7 @@ export class CollectionsService {
|
||||
}
|
||||
updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); }
|
||||
setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); }
|
||||
setRating(userId: number, placeId: number, rating: number | null, socketId?: string) { return svc.setRating(userId, placeId, rating, socketId); }
|
||||
setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string) { return svc.setPlaceImage(userId, placeId, imageUrl, socketId); }
|
||||
deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); }
|
||||
deletePlacesMany(userId: number, ids: number[], socketId?: string) { return svc.deletePlacesMany(userId, ids, socketId); }
|
||||
|
||||
@@ -24,6 +24,8 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload';
|
||||
import { placeImageUrl } from '../../services/placeImage';
|
||||
import { isDemoEmail } from '../../services/demo';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { placeRatingRequestSchema, type PlaceRatingRequest } from '@trek/shared';
|
||||
|
||||
const STRING_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
@@ -299,6 +301,36 @@ export class PlacesController {
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Put(':id/rating')
|
||||
rate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(placeRatingRequestSchema)) body: PlaceRatingRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
// Deliberately no place_edit check: every trip member may cast their own
|
||||
// vote (#1435), even when an admin restricts place editing.
|
||||
this.requireTrip(tripId, user);
|
||||
const place = this.places.rate(tripId, id, user.id, body.rating);
|
||||
if (!place) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Delete(':id/rating')
|
||||
unrate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
const place = this.places.rate(tripId, id, user.id, null);
|
||||
if (!place) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Get(':id/image')
|
||||
async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
|
||||
@@ -80,6 +80,10 @@ export class PlacesService {
|
||||
return svc.searchPlaceImage(tripId, id, userId);
|
||||
}
|
||||
|
||||
rate(tripId: string, id: string, userId: number, rating: number | null) {
|
||||
return svc.ratePlace(tripId, id, userId, rating);
|
||||
}
|
||||
|
||||
// Journey hooks — non-fatal, mirroring the route's try/catch wrappers.
|
||||
onCreated(tripId: string, placeId: number): void { try { onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } }
|
||||
onUpdated(placeId: number): void { try { onPlaceUpdated(placeId); } catch { /* non-fatal */ } }
|
||||
|
||||
@@ -171,12 +171,32 @@ function loadLabelIdsByPlaceIds(placeIds: number[]): Record<number, number[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-voter rating rows (#1435), batched (mirrors loadTagsByCollectionPlaceIds). */
|
||||
function loadRatingsByCollectionPlaceIds(placeIds: number[]): Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]> {
|
||||
const out: Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]> = {};
|
||||
if (placeIds.length === 0) return out;
|
||||
const rows = db.prepare(`
|
||||
SELECT cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, cpr.rating
|
||||
FROM collection_place_ratings cpr
|
||||
JOIN users u ON u.id = cpr.user_id
|
||||
WHERE cpr.collection_place_id IN (${placeIds.map(() => '?').join(',')})
|
||||
ORDER BY cpr.created_at
|
||||
`).all(...placeIds) as { pid: number; user_id: number; username: string; avatar: string | null; rating: number }[];
|
||||
for (const { pid, ...rest } of rows) {
|
||||
if (!out[pid]) out[pid] = [];
|
||||
out[pid].push(rest);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
||||
const ids = rows.map(r => r.id);
|
||||
const tagsByPlace = loadTagsByCollectionPlaceIds(ids);
|
||||
const labelsByPlace = loadLabelIdsByPlaceIds(ids);
|
||||
const ratingsByPlace = loadRatingsByCollectionPlaceIds(ids);
|
||||
return rows.map(r => {
|
||||
const { category_name, category_color, category_icon, ...rest } = r;
|
||||
const ratings = ratingsByPlace[r.id] || [];
|
||||
return {
|
||||
...rest,
|
||||
links: parseLinks((r as { links?: unknown }).links),
|
||||
@@ -185,6 +205,9 @@ function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
||||
: undefined,
|
||||
tags: tagsByPlace[r.id] || [],
|
||||
label_ids: labelsByPlace[r.id] || [],
|
||||
ratings,
|
||||
rating_avg: ratings.length > 0 ? ratings.reduce((s, x) => s + x.rating, 0) / ratings.length : null,
|
||||
rating_count: ratings.length,
|
||||
} as CollectionPlace;
|
||||
});
|
||||
}
|
||||
@@ -419,6 +442,28 @@ function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): vo
|
||||
for (const tid of tagIds) stmt.run(collectionPlaceId, tid);
|
||||
}
|
||||
|
||||
/** Owner + accepted members — the users whose votes may live in this list. */
|
||||
function collectionMemberIds(collectionId: number): Set<number> {
|
||||
const ids = new Set<number>([ownerOf(collectionId)]);
|
||||
const rows = db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(collectionId) as { user_id: number }[];
|
||||
rows.forEach(r => ids.add(r.user_id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry star votes (#1435) from a trip place into a freshly saved collection
|
||||
* place. Only votes by members of the target collection come along (the saver
|
||||
* is always a member) — other trip members' opinions stay in the trip.
|
||||
*/
|
||||
function copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): void {
|
||||
const eligible = collectionMemberIds(collectionId);
|
||||
const rows = db.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(sourcePlaceId) as { user_id: number; rating: number }[];
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)');
|
||||
for (const r of rows) {
|
||||
if (eligible.has(r.user_id)) ins.run(collectionPlaceId, r.user_id, r.rating);
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult {
|
||||
assertCanEdit(userId, body.collection_id);
|
||||
|
||||
@@ -446,6 +491,18 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest, sock
|
||||
|
||||
const placeId = Number(result.lastInsertRowid);
|
||||
attachTags(placeId, body.tag_ids);
|
||||
// Carry trip ratings ONLY when the caller can actually see the source place.
|
||||
// source_place_id/source_trip_id are raw client input, so verify trip access +
|
||||
// that the place lives in that trip before reading place_ratings — otherwise a
|
||||
// member could harvest co-members' votes on places in trips they cannot access
|
||||
// (mirrors the canAccessTrip gate in saveFromTripPlace).
|
||||
if (
|
||||
body.source_place_id && body.source_trip_id &&
|
||||
canAccessTrip(body.source_trip_id, userId) &&
|
||||
db.prepare('SELECT 1 FROM places WHERE id = ? AND trip_id = ?').get(body.source_place_id, body.source_trip_id)
|
||||
) {
|
||||
copyTripRatings(body.source_place_id, placeId, body.collection_id);
|
||||
}
|
||||
notifyCollectionUsers(body.collection_id, socketId, 'collections:updated');
|
||||
return { place: getPlaceById(placeId) };
|
||||
}
|
||||
@@ -511,7 +568,7 @@ export function saveFromTripPlaces(
|
||||
skipped.push({ id: placeId, name });
|
||||
continue;
|
||||
}
|
||||
insert.run(
|
||||
const res = insert.run(
|
||||
collectionId, ownerId, userId,
|
||||
name, (p.description as string | null) ?? null, lat, lng, (p.address as string | null) ?? null,
|
||||
(p.category_id as number | null) ?? null, (p.price as number | null) ?? null, (p.currency as string | null) ?? null, (p.notes as string | null) ?? null,
|
||||
@@ -519,6 +576,7 @@ export function saveFromTripPlaces(
|
||||
(p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null,
|
||||
tripId, placeId,
|
||||
);
|
||||
copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId);
|
||||
copied++;
|
||||
}
|
||||
if (copied > 0) notifyCollectionUsers(collectionId, undefined, 'collections:updated');
|
||||
@@ -586,6 +644,26 @@ export function setStatus(userId: number, placeId: number, status: CollectionSta
|
||||
return getPlaceById(placeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (1-5) or clear (null) the user's own star vote on a saved place (#1435).
|
||||
* Gated on assertAccess, not assertCanEdit — a vote is the member's personal
|
||||
* opinion, so read-only viewers get to cast one too.
|
||||
*/
|
||||
export function setRating(userId: number, placeId: number, rating: number | null, socketId?: string): CollectionPlace {
|
||||
const collectionId = collectionIdOfPlace(placeId);
|
||||
assertAccess(userId, collectionId);
|
||||
if (rating === null) {
|
||||
db.prepare('DELETE FROM collection_place_ratings WHERE collection_place_id = ? AND user_id = ?').run(placeId, userId);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)
|
||||
ON CONFLICT(collection_place_id, user_id) DO UPDATE SET rating = excluded.rating
|
||||
`).run(placeId, userId, rating);
|
||||
}
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
return getPlaceById(placeId);
|
||||
}
|
||||
|
||||
export function deletePlace(userId: number, placeId: number, socketId?: string): void {
|
||||
const collectionId = collectionIdOfPlace(placeId);
|
||||
assertCanDelete(userId, collectionId);
|
||||
@@ -635,6 +713,14 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): {
|
||||
httpError(403, 'Not allowed to edit this trip');
|
||||
}
|
||||
|
||||
// Votes only travel for users who are members of THIS trip (owner + members),
|
||||
// so copying never surfaces a collection member with no tie to the trip.
|
||||
// Symmetric with copyTripRatings' collection-member filter (#1435).
|
||||
const tripMemberIds = new Set<number>([trip.user_id]);
|
||||
for (const r of db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(body.trip_id) as { user_id: number }[]) {
|
||||
tripMemberIds.add(r.user_id);
|
||||
}
|
||||
|
||||
// Visibility on every SOURCE place — no cross-user exfiltration via copy.
|
||||
const sources: Array<{ id: number; name: string; description: string | null; lat: number | null; lng: number | null;
|
||||
address: string | null; category_id: number | null; price: number | null; currency: string | null;
|
||||
@@ -665,6 +751,7 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
||||
const insertRating = db.prepare('INSERT OR IGNORE INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)');
|
||||
|
||||
let copied = 0;
|
||||
const skipped: { id: number; name: string }[] = [];
|
||||
@@ -680,6 +767,11 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): {
|
||||
const newPlaceId = Number(res.lastInsertRowid);
|
||||
const tagIds = db.prepare('SELECT tag_id FROM collection_place_tags WHERE collection_place_id = ?').all(s.id) as { tag_id: number }[];
|
||||
for (const t of tagIds) insertTag.run(newPlaceId, t.tag_id);
|
||||
// Ratings travel into the trip too (#1435), but only for trip members — a
|
||||
// collection voter who isn't on the trip stays out of it. Trip members keep
|
||||
// voting there; nothing is mirrored back.
|
||||
const votes = db.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(s.id) as { user_id: number; rating: number }[];
|
||||
for (const v of votes) if (tripMemberIds.has(v.user_id)) insertRating.run(newPlaceId, v.user_id, v.rating);
|
||||
|
||||
if (s.name) dedup.names.add(s.name.trim().toLowerCase());
|
||||
else if (s.lat != null && s.lng != null) dedup.coords.push({ lat: s.lat, lng: s.lng });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { loadTagsByPlaceIds, loadRatingsByPlaceIds, ratingAggregate } from './queryHelpers';
|
||||
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||
import { Place } from '../types';
|
||||
import {
|
||||
@@ -97,6 +97,7 @@ export function listPlaces(
|
||||
|
||||
const placeIds = places.map(p => p.id);
|
||||
const tagsByPlaceId = loadTagsByPlaceIds(placeIds);
|
||||
const ratingsByPlaceId = loadRatingsByPlaceIds(placeIds);
|
||||
|
||||
return places.map(p => ({
|
||||
...p,
|
||||
@@ -107,6 +108,8 @@ export function listPlaces(
|
||||
icon: p.category_icon,
|
||||
} : null,
|
||||
tags: tagsByPlaceId[p.id] || [],
|
||||
ratings: ratingsByPlaceId[p.id] || [],
|
||||
...ratingAggregate(ratingsByPlaceId[p.id]),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -994,3 +997,27 @@ export async function searchPlaceImage(tripId: string, placeId: string, userId:
|
||||
|
||||
return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collaborative ratings (#1435)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set (rating 1-5) or clear (rating null) the user's own vote on a trip place.
|
||||
* Ratings live in their own table so voting never bumps places.updated_at —
|
||||
* a vote must not 409 another member's offline edit. Returns the refreshed
|
||||
* place (with the new aggregate) or null when the place isn't in the trip.
|
||||
*/
|
||||
export function ratePlace(tripId: string, placeId: string, userId: number, rating: number | null): ReturnType<typeof getPlaceWithTags> | null {
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
if (!place) return null;
|
||||
if (rating === null) {
|
||||
db.prepare('DELETE FROM place_ratings WHERE place_id = ? AND user_id = ?').run(placeId, userId);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)
|
||||
ON CONFLICT(place_id, user_id) DO UPDATE SET rating = excluded.rating
|
||||
`).run(placeId, userId, rating);
|
||||
}
|
||||
return getPlaceWithTags(placeId);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,40 @@ function loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean
|
||||
return tagsByPlaceId;
|
||||
}
|
||||
|
||||
export interface PlaceRatingRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
/** Batch-load collaborative ratings (#1435) for multiple places in one query, indexed by place ID. */
|
||||
function loadRatingsByPlaceIds(placeIds: number[]): Record<number, PlaceRatingRow[]> {
|
||||
const ratingsByPlaceId: Record<number, PlaceRatingRow[]> = {};
|
||||
if (placeIds.length > 0) {
|
||||
const rows = db.prepare(`
|
||||
SELECT pr.place_id, pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr
|
||||
JOIN users u ON pr.user_id = u.id
|
||||
WHERE pr.place_id IN (${placeIds.map(() => '?').join(',')})
|
||||
ORDER BY pr.created_at
|
||||
`).all(...placeIds) as (PlaceRatingRow & { place_id: number })[];
|
||||
for (const { place_id, ...rest } of rows) {
|
||||
if (!ratingsByPlaceId[place_id]) ratingsByPlaceId[place_id] = [];
|
||||
ratingsByPlaceId[place_id].push(rest);
|
||||
}
|
||||
}
|
||||
return ratingsByPlaceId;
|
||||
}
|
||||
|
||||
/** avg/count aggregate for a place's rating rows. */
|
||||
function ratingAggregate(ratings: PlaceRatingRow[] | undefined) {
|
||||
const rows = ratings || [];
|
||||
return {
|
||||
rating_avg: rows.length > 0 ? rows.reduce((s, r) => s + r.rating, 0) / rows.length : null,
|
||||
rating_count: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Batch-load participants for multiple day-assignments in a single query, indexed by assignment ID. */
|
||||
function loadParticipantsByAssignmentIds(assignmentIds: number[]): Record<number, Participant[]> {
|
||||
const participantsByAssignment: Record<number, Participant[]> = {};
|
||||
@@ -95,4 +129,4 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
|
||||
};
|
||||
}
|
||||
|
||||
export { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace };
|
||||
export { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace, loadRatingsByPlaceIds, ratingAggregate };
|
||||
|
||||
@@ -614,3 +614,138 @@ describe('custom saved-place image', () => {
|
||||
expect(fs.existsSync(fileA)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Collaborative ratings (#1435) ────────────────────────────────────────────
|
||||
|
||||
describe('collaborative ratings (#1435)', () => {
|
||||
it('COLLECTIONS-SVC-070: setRating stores a vote, updates it, and clears with null', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = svc.createCollection(u.id, { name: 'Rate' });
|
||||
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Louvre' }).place!;
|
||||
|
||||
let updated = svc.setRating(u.id, p.id, 5);
|
||||
expect(updated.rating_avg).toBe(5);
|
||||
expect(updated.rating_count).toBe(1);
|
||||
expect(updated.ratings?.find(r => r.user_id === u.id)?.rating).toBe(5);
|
||||
|
||||
updated = svc.setRating(u.id, p.id, 3); // same user re-votes → replaces, not appends
|
||||
expect(updated.rating_avg).toBe(3);
|
||||
expect(updated.rating_count).toBe(1);
|
||||
|
||||
updated = svc.setRating(u.id, p.id, null); // clear
|
||||
expect(updated.rating_avg).toBeNull();
|
||||
expect(updated.rating_count).toBe(0);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-071: every accepted member may vote; the value is the average', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = svc.createCollection(owner.id, { name: 'Shared rate' });
|
||||
// A viewer (read-only) member — still allowed to cast a personal vote.
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id);
|
||||
const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' }).place!;
|
||||
|
||||
svc.setRating(owner.id, p.id, 5);
|
||||
const updated = svc.setRating(member.id, p.id, 2);
|
||||
expect(updated.rating_count).toBe(2);
|
||||
expect(updated.rating_avg).toBe(3.5);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-072: a non-member cannot rate', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const outsider = createUser(testDb).user;
|
||||
const col = svc.createCollection(owner.id, { name: 'Private rate' });
|
||||
const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Secret' }).place!;
|
||||
expect(() => svc.setRating(outsider.id, p.id, 4)).toThrow();
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-073: saving a trip place copies only the saver + shared-member votes', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const shared = createUser(testDb).user; // member of BOTH the trip and the collection
|
||||
const tripOnly = createUser(testDb).user; // member of the trip only
|
||||
const col = svc.createCollection(owner.id, { name: 'From trip rated' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id);
|
||||
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, shared.id);
|
||||
addTripMember(testDb, trip.id, tripOnly.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
||||
const ins = testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)');
|
||||
ins.run(place.id, owner.id, 5);
|
||||
ins.run(place.id, shared.id, 4);
|
||||
ins.run(place.id, tripOnly.id, 1);
|
||||
|
||||
const saved = svc.savePlace(owner.id, {
|
||||
collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id,
|
||||
}).place!;
|
||||
|
||||
const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[];
|
||||
const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b);
|
||||
expect(voterIds).toEqual([owner.id, shared.id].sort((a, b) => a - b));
|
||||
expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = svc.createCollection(owner.id, { name: 'Copyable' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id);
|
||||
const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' }).place!;
|
||||
svc.setRating(owner.id, cp.id, 5);
|
||||
svc.setRating(member.id, cp.id, 3);
|
||||
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries
|
||||
const res = svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] });
|
||||
expect(res.copied).toBe(1);
|
||||
|
||||
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number };
|
||||
const votes = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number; rating: number }[];
|
||||
expect(votes).toHaveLength(2);
|
||||
expect(votes.find(v => v.user_id === owner.id)?.rating).toBe(5);
|
||||
expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-075: savePlace does NOT harvest ratings from a source place the caller cannot access', () => {
|
||||
const attacker = createUser(testDb).user;
|
||||
const victim = createUser(testDb).user;
|
||||
const col = svc.createCollection(attacker.id, { name: 'Harvest attempt' });
|
||||
// The victim is a member of the attacker's collection (so they'd be "eligible").
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id);
|
||||
// A PRIVATE trip the attacker is not on, with the victim's vote on a place.
|
||||
const privateTrip = createTrip(testDb, victim.id);
|
||||
const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' });
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5);
|
||||
|
||||
const saved = svc.savePlace(attacker.id, {
|
||||
collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id,
|
||||
}).place!;
|
||||
|
||||
const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id);
|
||||
expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-076: copyToTrip carries only votes from members of the target trip', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const inTrip = createUser(testDb).user; // collection member AND trip member
|
||||
const notInTrip = createUser(testDb).user; // collection member only
|
||||
const col = svc.createCollection(owner.id, { name: 'Mixed membership' });
|
||||
for (const u of [inTrip, notInTrip]) {
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id);
|
||||
}
|
||||
const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' }).place!;
|
||||
svc.setRating(owner.id, cp.id, 5);
|
||||
svc.setRating(inTrip.id, cp.id, 4);
|
||||
svc.setRating(notInTrip.id, cp.id, 1);
|
||||
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, inTrip.id);
|
||||
svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] });
|
||||
|
||||
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number };
|
||||
const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[])
|
||||
.map(v => v.user_id).sort((a, b) => a - b);
|
||||
expect(ids).toEqual([owner.id, inTrip.id].sort((a, b) => a - b));
|
||||
expect(ids).not.toContain(notInTrip.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage, ratePlace } from '../../../src/services/placeService';
|
||||
import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage';
|
||||
|
||||
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
|
||||
@@ -764,4 +764,34 @@ describe('custom place image reclaim', () => {
|
||||
testDb.exec('DELETE FROM collection_places; DELETE FROM collections;');
|
||||
fs.unlinkSync(fileA);
|
||||
});
|
||||
|
||||
// ── Collaborative ratings (#1435) ──────────────────────────────────────────
|
||||
it('PLACE-SVC-050 — ratePlace stores one vote per user, replaces on re-vote, clears with null', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Rated' }) as { id: number };
|
||||
|
||||
ratePlace(String(trip.id), String(place.id), user.id, 5);
|
||||
let rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[];
|
||||
expect(rows).toEqual([{ rating: 5 }]);
|
||||
|
||||
ratePlace(String(trip.id), String(place.id), user.id, 2); // re-vote replaces via the UNIQUE upsert
|
||||
rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[];
|
||||
expect(rows).toEqual([{ rating: 2 }]);
|
||||
|
||||
ratePlace(String(trip.id), String(place.id), user.id, null); // clear
|
||||
const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number };
|
||||
expect(count.n).toBe(0);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-051 — ratePlace returns null and writes nothing when the place is not in the trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }) as { id: number };
|
||||
|
||||
expect(ratePlace(String(trip.id), String(place.id), user.id, 4)).toBeNull();
|
||||
const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number };
|
||||
expect(count.n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { placeCategorySchema } from '../place/place.schema';
|
||||
import { placeCategorySchema, placeRatingVoteSchema } from '../place/place.schema';
|
||||
import { tagSchema } from '../tag/tag.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -70,6 +70,10 @@ export const collectionPlaceSchema = z.object({
|
||||
tags: z.array(tagSchema.partial()).optional(),
|
||||
/** Ids of the per-collection labels assigned to this place. */
|
||||
label_ids: z.array(z.number()).optional(),
|
||||
// Collaborative ratings (#1435): every member's vote + the displayed aggregate.
|
||||
ratings: z.array(placeRatingVoteSchema).optional(),
|
||||
rating_avg: z.number().nullable().optional(),
|
||||
rating_count: z.number().optional(),
|
||||
});
|
||||
export type CollectionPlace = z.infer<typeof collectionPlaceSchema>;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'الرحلات',
|
||||
'oauth.scope.group.places': 'الأماكن',
|
||||
'oauth.scope.group.collections': 'المجموعات',
|
||||
'oauth.scope.group.packing': 'الأمتعة',
|
||||
'oauth.scope.group.todos': 'المهام',
|
||||
'oauth.scope.group.budget': 'الميزانية',
|
||||
@@ -24,6 +25,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'قراءة الأماكن وتعيينات الأيام والعلامات والفئات',
|
||||
'oauth.scope.places:write.label': 'إدارة الأماكن',
|
||||
'oauth.scope.places:write.description': 'إنشاء وتحديث وحذف الأماكن والتعيينات والعلامات',
|
||||
'oauth.scope.collections:read.label': 'عرض المجموعات',
|
||||
'oauth.scope.collections:read.description': 'قراءة مجموعات الأماكن المحفوظة وأماكنها وتقييماتها وتسمياتها وأعضائها',
|
||||
'oauth.scope.collections:write.label': 'إدارة المجموعات',
|
||||
'oauth.scope.collections:write.description': 'إنشاء وتعديل المجموعات، وحفظ الأماكن وتقييمها وتسميتها ونسخها، ومشاركة القوائم',
|
||||
'oauth.scope.atlas:read.label': 'عرض Atlas',
|
||||
'oauth.scope.atlas:read.description': 'قراءة الدول والمناطق المزارة وقائمة الأمنيات',
|
||||
'oauth.scope.atlas:write.label': 'إدارة Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'الكل',
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.filterTracks': 'المسارات',
|
||||
'places.sortByRating': 'الترتيب حسب التقييم',
|
||||
'places.yourRating': 'تقييمك',
|
||||
'places.notRated': 'لم يُقيَّم بعد',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
'places.allCategories': 'كل الفئات',
|
||||
'places.categoriesSelected': 'فئات',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Viagens',
|
||||
'oauth.scope.group.places': 'Locais',
|
||||
'oauth.scope.group.collections': 'Coleções',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Bagagem',
|
||||
'oauth.scope.group.todos': 'Tarefas',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Ler locais, atribuições de dias, tags e categorias',
|
||||
'oauth.scope.places:write.label': 'Gerenciar locais',
|
||||
'oauth.scope.places:write.description': 'Criar, atualizar e excluir locais, atribuições e tags',
|
||||
'oauth.scope.collections:read.label': 'Ver coleções',
|
||||
'oauth.scope.collections:read.description': 'Ler coleções de lugares salvos, seus lugares, avaliações, etiquetas e membros',
|
||||
'oauth.scope.collections:write.label': 'Gerenciar coleções',
|
||||
'oauth.scope.collections:write.description': 'Criar/editar coleções, salvar, avaliar, etiquetar e copiar lugares, e compartilhar listas',
|
||||
'oauth.scope.atlas:read.label': 'Ver Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Ler países visitados, regiões e lista de desejos',
|
||||
'oauth.scope.atlas:write.label': 'Gerenciar Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Todos',
|
||||
'places.unplanned': 'Não planejados',
|
||||
'places.filterTracks': 'Trilhas',
|
||||
'places.sortByRating': 'Ordenar por avaliação',
|
||||
'places.yourRating': 'Sua avaliação',
|
||||
'places.notRated': 'Ainda sem avaliações',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas as categorias',
|
||||
'places.categoriesSelected': 'categorias',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Viatges',
|
||||
'oauth.scope.group.places': 'Llocs',
|
||||
'oauth.scope.group.collections': 'Col·leccions',
|
||||
'oauth.scope.group.atlas': 'Atles',
|
||||
'oauth.scope.group.packing': 'Equipatge',
|
||||
'oauth.scope.group.todos': 'Tasques',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Llegeix llocs, assignacions de dies, etiquetes i categories',
|
||||
'oauth.scope.places:write.label': 'Gestiona els llocs',
|
||||
'oauth.scope.places:write.description': 'Crea, actualitza i elimina llocs, assignacions i etiquetes',
|
||||
'oauth.scope.collections:read.label': 'Mostra les col·leccions',
|
||||
'oauth.scope.collections:read.description': 'Llegeix les col·leccions de llocs desats, els seus llocs, valoracions, etiquetes i membres',
|
||||
'oauth.scope.collections:write.label': 'Gestiona les col·leccions',
|
||||
'oauth.scope.collections:write.description': 'Crea i edita col·leccions; desa, valora, etiqueta i copia llocs, i comparteix llistes',
|
||||
'oauth.scope.atlas:read.label': "Mostra l'Atles",
|
||||
'oauth.scope.atlas:read.description': 'Llegeix països visitats, regions i llista de desitjos',
|
||||
'oauth.scope.atlas:write.label': "Gestiona l'Atles",
|
||||
|
||||
@@ -41,6 +41,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Tot',
|
||||
'places.unplanned': 'Sense planificar',
|
||||
'places.filterTracks': 'Rutes',
|
||||
'places.sortByRating': 'Ordena per valoració',
|
||||
'places.yourRating': 'La teva valoració',
|
||||
'places.notRated': 'Encara sense valoració',
|
||||
'places.search': 'Cerca llocs...',
|
||||
'places.allCategories': 'Totes les categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Výlety',
|
||||
'oauth.scope.group.places': 'Místa',
|
||||
'oauth.scope.group.collections': 'Kolekce',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Balení',
|
||||
'oauth.scope.group.todos': 'Úkoly',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Číst místa, denní přiřazení, štítky a kategorie',
|
||||
'oauth.scope.places:write.label': 'Spravovat místa',
|
||||
'oauth.scope.places:write.description': 'Vytvářet, aktualizovat a mazat místa, přiřazení a štítky',
|
||||
'oauth.scope.collections:read.label': 'Zobrazit kolekce',
|
||||
'oauth.scope.collections:read.description': 'Číst kolekce uložených míst, jejich místa, hodnocení, štítky a členy',
|
||||
'oauth.scope.collections:write.label': 'Spravovat kolekce',
|
||||
'oauth.scope.collections:write.description': 'Vytvářet a upravovat kolekce, ukládat, hodnotit, označovat a kopírovat místa a sdílet seznamy',
|
||||
'oauth.scope.atlas:read.label': 'Zobrazit Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Číst navštívené země, regiony a seznam přání',
|
||||
'oauth.scope.atlas:write.label': 'Spravovat Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Vše',
|
||||
'places.unplanned': 'Nezařazené',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.sortByRating': 'Seřadit podle hodnocení',
|
||||
'places.yourRating': 'Tvé hodnocení',
|
||||
'places.notRated': 'Zatím bez hodnocení',
|
||||
'places.search': 'Hledat místa...',
|
||||
'places.allCategories': 'Všechny kategorie',
|
||||
'places.categoriesSelected': 'kategorií',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Reisen',
|
||||
'oauth.scope.group.places': 'Orte',
|
||||
'oauth.scope.group.collections': 'Sammlungen',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Packliste',
|
||||
'oauth.scope.group.todos': 'Aufgaben',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Orte, Tageszuweisungen, Tags und Kategorien lesen',
|
||||
'oauth.scope.places:write.label': 'Orte verwalten',
|
||||
'oauth.scope.places:write.description': 'Orte, Zuweisungen und Tags erstellen, aktualisieren und löschen',
|
||||
'oauth.scope.collections:read.label': 'Sammlungen ansehen',
|
||||
'oauth.scope.collections:read.description': 'Sammlungen gespeicherter Orte, ihre Orte, Bewertungen, Labels und Mitglieder lesen',
|
||||
'oauth.scope.collections:write.label': 'Sammlungen verwalten',
|
||||
'oauth.scope.collections:write.description': 'Sammlungen erstellen/bearbeiten, Orte speichern, bewerten, labeln und kopieren, Listen teilen',
|
||||
'oauth.scope.atlas:read.label': 'Atlas anzeigen',
|
||||
'oauth.scope.atlas:read.description': 'Besuchte Länder, Regionen und Wunschliste lesen',
|
||||
'oauth.scope.atlas:write.label': 'Atlas verwalten',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.sortByRating': 'Nach Bewertung sortieren',
|
||||
'places.yourRating': 'Deine Bewertung',
|
||||
'places.notRated': 'Noch nicht bewertet',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Trips',
|
||||
'oauth.scope.group.places': 'Places',
|
||||
'oauth.scope.group.collections': 'Collections',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Packing',
|
||||
'oauth.scope.group.todos': 'To-dos',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Read places, day assignments, tags, and categories',
|
||||
'oauth.scope.places:write.label': 'Manage places',
|
||||
'oauth.scope.places:write.description': 'Create, update, and delete places, assignments, and tags',
|
||||
'oauth.scope.collections:read.label': 'View collections',
|
||||
'oauth.scope.collections:read.description': 'Read saved-place collections, their places, ratings, labels, and members',
|
||||
'oauth.scope.collections:write.label': 'Manage collections',
|
||||
'oauth.scope.collections:write.description': 'Create/edit collections, save, rate, label and copy places, and share lists',
|
||||
'oauth.scope.atlas:read.label': 'View Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Read visited countries, regions, and bucket list',
|
||||
'oauth.scope.atlas:write.label': 'Manage Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.sortByRating': 'Sort by rating',
|
||||
'places.yourRating': 'Your rating',
|
||||
'places.notRated': 'Not rated yet',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Viajes',
|
||||
'oauth.scope.group.places': 'Lugares',
|
||||
'oauth.scope.group.collections': 'Colecciones',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Equipaje',
|
||||
'oauth.scope.group.todos': 'Tareas',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Leer lugares, asignaciones de días, etiquetas y categorías',
|
||||
'oauth.scope.places:write.label': 'Gestionar lugares',
|
||||
'oauth.scope.places:write.description': 'Crear, actualizar y eliminar lugares, asignaciones y etiquetas',
|
||||
'oauth.scope.collections:read.label': 'Ver colecciones',
|
||||
'oauth.scope.collections:read.description': 'Leer las colecciones de lugares guardados, sus lugares, valoraciones, etiquetas y miembros',
|
||||
'oauth.scope.collections:write.label': 'Gestionar colecciones',
|
||||
'oauth.scope.collections:write.description': 'Crear/editar colecciones, guardar, valorar, etiquetar y copiar lugares, y compartir listas',
|
||||
'oauth.scope.atlas:read.label': 'Ver Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Leer países visitados, regiones y lista de deseos',
|
||||
'oauth.scope.atlas:write.label': 'Gestionar Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.sortByRating': 'Ordenar por valoración',
|
||||
'places.yourRating': 'Tu valoración',
|
||||
'places.notRated': 'Sin valorar todavía',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Voyages',
|
||||
'oauth.scope.group.places': 'Lieux',
|
||||
'oauth.scope.group.collections': 'Collections',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Bagages',
|
||||
'oauth.scope.group.todos': 'Tâches',
|
||||
@@ -27,6 +28,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Lire les lieux, affectations de jours, étiquettes et catégories',
|
||||
'oauth.scope.places:write.label': 'Gérer les lieux',
|
||||
'oauth.scope.places:write.description': 'Créer, modifier et supprimer des lieux, affectations et étiquettes',
|
||||
'oauth.scope.collections:read.label': 'Voir les collections',
|
||||
'oauth.scope.collections:read.description': 'Consulter les collections de lieux enregistrés, ainsi que leurs lieux, notes, étiquettes et membres',
|
||||
'oauth.scope.collections:write.label': 'Gérer les collections',
|
||||
'oauth.scope.collections:write.description': 'Créer/modifier des collections, enregistrer, noter, étiqueter et copier des lieux, et partager des listes',
|
||||
'oauth.scope.atlas:read.label': "Voir l'Atlas",
|
||||
'oauth.scope.atlas:read.description': 'Lire les pays visités, régions et liste de souhaits',
|
||||
'oauth.scope.atlas:write.label': "Gérer l'Atlas",
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.filterTracks': 'Traces',
|
||||
'places.sortByRating': 'Trier par note',
|
||||
'places.yourRating': 'Ta note',
|
||||
'places.notRated': 'Pas encore noté',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.categoriesSelected': 'catégories',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Ταξίδια',
|
||||
'oauth.scope.group.places': 'Τοποθεσίες',
|
||||
'oauth.scope.group.collections': 'Συλλογές',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Αποσκευές',
|
||||
'oauth.scope.group.todos': 'Εργασίες',
|
||||
@@ -27,6 +28,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Ανάγνωση τοποθεσιών, αναθέσεων ημερών, ετικετών και κατηγοριών',
|
||||
'oauth.scope.places:write.label': 'Διαχείριση τοποθεσιών',
|
||||
'oauth.scope.places:write.description': 'Δημιουργία, ενημέρωση και διαγραφή τοποθεσιών, αναθέσεων και ετικετών',
|
||||
'oauth.scope.collections:read.label': 'Προβολή συλλογών',
|
||||
'oauth.scope.collections:read.description': 'Ανάγνωση συλλογών αποθηκευμένων τοποθεσιών, των τοποθεσιών τους, των βαθμολογιών, των ετικετών και των μελών τους',
|
||||
'oauth.scope.collections:write.label': 'Διαχείριση συλλογών',
|
||||
'oauth.scope.collections:write.description': 'Δημιουργία/επεξεργασία συλλογών, αποθήκευση, βαθμολόγηση, προσθήκη ετικετών και αντιγραφή τοποθεσιών και κοινή χρήση λιστών',
|
||||
'oauth.scope.atlas:read.label': 'Προβολή Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Ανάγνωση περιοχών που έχουν επισκεφθεί, χωρών και λίστας επιθυμιών',
|
||||
'oauth.scope.atlas:write.label': 'Διαχείριση Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Όλα',
|
||||
'places.unplanned': 'Μη προγραμματισμένα',
|
||||
'places.filterTracks': 'Ίχνη',
|
||||
'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία',
|
||||
'places.yourRating': 'Η βαθμολογία σου',
|
||||
'places.notRated': 'Χωρίς βαθμολογία ακόμη',
|
||||
'places.search': 'Αναζήτηση μερών...',
|
||||
'places.allCategories': 'Όλες οι Κατηγορίες',
|
||||
'places.categoriesSelected': 'κατηγορίες',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Utazások',
|
||||
'oauth.scope.group.places': 'Helyek',
|
||||
'oauth.scope.group.collections': 'Gyűjtemények',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Csomagolás',
|
||||
'oauth.scope.group.todos': 'Feladatok',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Helyek, napi hozzárendelések, címkék és kategóriák olvasása',
|
||||
'oauth.scope.places:write.label': 'Helyek kezelése',
|
||||
'oauth.scope.places:write.description': 'Helyek, hozzárendelések és címkék létrehozása, frissítése és törlése',
|
||||
'oauth.scope.collections:read.label': 'Gyűjtemények megtekintése',
|
||||
'oauth.scope.collections:read.description': 'Helygyűjtemények, azok helyeinek, értékeléseinek, címkéinek és tagjainak olvasása',
|
||||
'oauth.scope.collections:write.label': 'Gyűjtemények kezelése',
|
||||
'oauth.scope.collections:write.description': 'Gyűjtemények létrehozása/szerkesztése, helyek mentése, értékelése, címkézése és másolása, valamint listák megosztása',
|
||||
'oauth.scope.atlas:read.label': 'Atlas megtekintése',
|
||||
'oauth.scope.atlas:read.description': 'Meglátogatott országok, régiók és bakancslisták olvasása',
|
||||
'oauth.scope.atlas:write.label': 'Atlas kezelése',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Összes',
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
'places.filterTracks': 'Nyomvonalak',
|
||||
'places.sortByRating': 'Rendezés értékelés szerint',
|
||||
'places.yourRating': 'Az értékelésed',
|
||||
'places.notRated': 'Még nincs értékelve',
|
||||
'places.search': 'Helyek keresése...',
|
||||
'places.allCategories': 'Összes kategória',
|
||||
'places.categoriesSelected': 'kategória',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Perjalanan',
|
||||
'oauth.scope.group.places': 'Tempat',
|
||||
'oauth.scope.group.collections': 'Koleksi',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Perlengkapan',
|
||||
'oauth.scope.group.todos': 'To-do',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Baca tempat, penugasan hari, tag, dan kategori',
|
||||
'oauth.scope.places:write.label': 'Kelola tempat',
|
||||
'oauth.scope.places:write.description': 'Buat, perbarui, dan hapus tempat, penugasan, dan tag',
|
||||
'oauth.scope.collections:read.label': 'Lihat koleksi',
|
||||
'oauth.scope.collections:read.description': 'Baca koleksi tempat tersimpan beserta tempat, rating, label, dan anggotanya',
|
||||
'oauth.scope.collections:write.label': 'Kelola koleksi',
|
||||
'oauth.scope.collections:write.description': 'Buat/edit koleksi, simpan, beri rating, beri label, dan salin tempat, serta bagikan daftar',
|
||||
'oauth.scope.atlas:read.label': 'Lihat Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Baca negara yang dikunjungi, wilayah, dan daftar impian',
|
||||
'oauth.scope.atlas:write.label': 'Kelola Atlas',
|
||||
|
||||
@@ -42,6 +42,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Semua',
|
||||
'places.unplanned': 'Belum direncanakan',
|
||||
'places.filterTracks': 'Trek',
|
||||
'places.sortByRating': 'Urutkan berdasarkan rating',
|
||||
'places.yourRating': 'Rating kamu',
|
||||
'places.notRated': 'Belum dinilai',
|
||||
'places.search': 'Cari tempat...',
|
||||
'places.allCategories': 'Semua Kategori',
|
||||
'places.categoriesSelected': 'kategori',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Viaggi',
|
||||
'oauth.scope.group.places': 'Luoghi',
|
||||
'oauth.scope.group.collections': 'Raccolte',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Bagagli',
|
||||
'oauth.scope.group.todos': 'Attività',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Leggi luoghi, assegnazioni giornaliere, tag e categorie',
|
||||
'oauth.scope.places:write.label': 'Gestisci luoghi',
|
||||
'oauth.scope.places:write.description': 'Crea, aggiorna ed elimina luoghi, assegnazioni e tag',
|
||||
'oauth.scope.collections:read.label': 'Visualizza raccolte',
|
||||
'oauth.scope.collections:read.description': 'Leggi le raccolte di luoghi salvati, i relativi luoghi, valutazioni, etichette e membri',
|
||||
'oauth.scope.collections:write.label': 'Gestisci raccolte',
|
||||
'oauth.scope.collections:write.description': 'Crea/modifica raccolte, salva, valuta, etichetta e copia luoghi e condividi elenchi',
|
||||
'oauth.scope.atlas:read.label': 'Visualizza Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Leggi paesi visitati, regioni e lista dei desideri',
|
||||
'oauth.scope.atlas:write.label': 'Gestisci Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Tutti',
|
||||
'places.unplanned': 'Non pianificati',
|
||||
'places.filterTracks': 'Tracce',
|
||||
'places.sortByRating': 'Ordina per valutazione',
|
||||
'places.yourRating': 'La tua valutazione',
|
||||
'places.notRated': 'Non ancora valutato',
|
||||
'places.search': 'Cerca luoghi...',
|
||||
'places.allCategories': 'Tutte le categorie',
|
||||
'places.categoriesSelected': 'categorie',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': '旅行',
|
||||
'oauth.scope.group.places': '場所',
|
||||
'oauth.scope.group.collections': 'コレクション',
|
||||
'oauth.scope.group.atlas': '地図',
|
||||
'oauth.scope.group.packing': '持ち物',
|
||||
'oauth.scope.group.todos': 'ToDo',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': '場所、日への割り当て、タグ、カテゴリを閲覧',
|
||||
'oauth.scope.places:write.label': '場所を管理',
|
||||
'oauth.scope.places:write.description': '場所、割り当て、タグの作成・更新・削除',
|
||||
'oauth.scope.collections:read.label': 'コレクションを表示',
|
||||
'oauth.scope.collections:read.description': '保存した場所のコレクションと、その中の場所、評価、ラベル、メンバーを読み取る',
|
||||
'oauth.scope.collections:write.label': 'コレクションを管理',
|
||||
'oauth.scope.collections:write.description': 'コレクションの作成・編集、場所の保存・評価・ラベル付け・コピー、リストの共有',
|
||||
'oauth.scope.atlas:read.label': '地図を表示',
|
||||
'oauth.scope.atlas:read.description': '訪問した国・地域、バケットリストを閲覧',
|
||||
'oauth.scope.atlas:write.label': '地図を管理',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'すべて',
|
||||
'places.unplanned': '未計画',
|
||||
'places.filterTracks': 'トラック',
|
||||
'places.sortByRating': '評価順に並べ替え',
|
||||
'places.yourRating': 'あなたの評価',
|
||||
'places.notRated': '未評価',
|
||||
'places.search': '場所を検索…',
|
||||
'places.allCategories': 'すべてのカテゴリ',
|
||||
'places.categoriesSelected': 'カテゴリ',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': '여행',
|
||||
'oauth.scope.group.places': '장소',
|
||||
'oauth.scope.group.collections': '컬렉션',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': '짐 목록',
|
||||
'oauth.scope.group.todos': '할 일',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': '장소, 날 배정, 태그, 카테고리 읽기',
|
||||
'oauth.scope.places:write.label': '장소 관리',
|
||||
'oauth.scope.places:write.description': '장소, 배정, 태그 만들기, 업데이트, 삭제',
|
||||
'oauth.scope.collections:read.label': '컬렉션 보기',
|
||||
'oauth.scope.collections:read.description': '저장된 장소 컬렉션과 장소, 평점, 라벨, 멤버 읽기',
|
||||
'oauth.scope.collections:write.label': '컬렉션 관리',
|
||||
'oauth.scope.collections:write.description': '컬렉션 생성·편집, 장소 저장·평가·라벨 지정·복사, 목록 공유',
|
||||
'oauth.scope.atlas:read.label': 'Atlas 보기',
|
||||
'oauth.scope.atlas:read.description': '방문한 나라, 지역, 버킷 리스트 읽기',
|
||||
'oauth.scope.atlas:write.label': 'Atlas 관리',
|
||||
|
||||
@@ -42,6 +42,9 @@ const places: TranslationStrings = {
|
||||
'places.all': '전체',
|
||||
'places.unplanned': '미계획',
|
||||
'places.filterTracks': '트랙',
|
||||
'places.sortByRating': '평점순 정렬',
|
||||
'places.yourRating': '내 평점',
|
||||
'places.notRated': '아직 평점 없음',
|
||||
'places.search': '장소 검색...',
|
||||
'places.allCategories': '모든 카테고리',
|
||||
'places.categoriesSelected': '카테고리',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Reizen',
|
||||
'oauth.scope.group.places': 'Plaatsen',
|
||||
'oauth.scope.group.collections': 'Collecties',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Paklijst',
|
||||
'oauth.scope.group.todos': 'Taken',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Plaatsen, dagtoewijzingen, tags en categorieën lezen',
|
||||
'oauth.scope.places:write.label': 'Plaatsen beheren',
|
||||
'oauth.scope.places:write.description': 'Plaatsen, toewijzingen en tags aanmaken, bijwerken en verwijderen',
|
||||
'oauth.scope.collections:read.label': 'Collecties bekijken',
|
||||
'oauth.scope.collections:read.description': 'Collecties met opgeslagen plekken lezen, inclusief hun plekken, beoordelingen, labels en leden',
|
||||
'oauth.scope.collections:write.label': 'Collecties beheren',
|
||||
'oauth.scope.collections:write.description': 'Collecties maken/bewerken, plekken opslaan, beoordelen, labelen en kopiëren, en lijsten delen',
|
||||
'oauth.scope.atlas:read.label': 'Atlas bekijken',
|
||||
'oauth.scope.atlas:read.description': "Bezochte landen, regio's en bucketlist lezen",
|
||||
'oauth.scope.atlas:write.label': 'Atlas beheren',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ongepland',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.sortByRating': 'Sorteren op beoordeling',
|
||||
'places.yourRating': 'Jouw beoordeling',
|
||||
'places.notRated': 'Nog niet beoordeeld',
|
||||
'places.search': 'Plaatsen zoeken...',
|
||||
'places.allCategories': 'Alle categorieën',
|
||||
'places.categoriesSelected': 'categorieën',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Podróże',
|
||||
'oauth.scope.group.places': 'Miejsca',
|
||||
'oauth.scope.group.collections': 'Kolekcje',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Pakowanie',
|
||||
'oauth.scope.group.todos': 'Zadania',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Odczytuj miejsca, przypisania dni, tagi i kategorie',
|
||||
'oauth.scope.places:write.label': 'Zarządzaj miejscami',
|
||||
'oauth.scope.places:write.description': 'Twórz, aktualizuj i usuń miejsca, przypisania i tagi',
|
||||
'oauth.scope.collections:read.label': 'Wyświetlanie kolekcji',
|
||||
'oauth.scope.collections:read.description': 'Odczyt kolekcji zapisanych miejsc, zawartych w nich miejsc, ocen, etykiet i członków',
|
||||
'oauth.scope.collections:write.label': 'Zarządzanie kolekcjami',
|
||||
'oauth.scope.collections:write.description': 'Tworzenie/edytowanie kolekcji, zapisywanie, ocenianie, etykietowanie i kopiowanie miejsc oraz udostępnianie list',
|
||||
'oauth.scope.atlas:read.label': 'Przeglądaj Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Odczytuj odwiedzone kraje, regiony i listę marzeń',
|
||||
'oauth.scope.atlas:write.label': 'Zarządzaj Atlasem',
|
||||
|
||||
@@ -34,6 +34,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Wszystkie',
|
||||
'places.unplanned': 'Niezaplanowane',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.sortByRating': 'Sortuj według oceny',
|
||||
'places.yourRating': 'Twoja ocena',
|
||||
'places.notRated': 'Jeszcze nie oceniono',
|
||||
'places.search': 'Szukaj miejsc...',
|
||||
'places.allCategories': 'Wszystkie kategorie',
|
||||
'places.categoriesSelected': 'kategorii',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Поездки',
|
||||
'oauth.scope.group.places': 'Места',
|
||||
'oauth.scope.group.collections': 'Коллекции',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Вещи',
|
||||
'oauth.scope.group.todos': 'Задачи',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Чтение мест, назначений по дням, тегов и категорий',
|
||||
'oauth.scope.places:write.label': 'Управление местами',
|
||||
'oauth.scope.places:write.description': 'Создание, обновление и удаление мест, назначений и тегов',
|
||||
'oauth.scope.collections:read.label': 'Просмотр коллекций',
|
||||
'oauth.scope.collections:read.description': 'Чтение коллекций сохранённых мест, входящих в них мест, оценок, меток и участников',
|
||||
'oauth.scope.collections:write.label': 'Управление коллекциями',
|
||||
'oauth.scope.collections:write.description': 'Создание и изменение коллекций, сохранение, оценка, добавление меток и копирование мест, а также предоставление доступа к спискам',
|
||||
'oauth.scope.atlas:read.label': 'Просмотр Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Чтение посещённых стран, регионов и списка желаний',
|
||||
'oauth.scope.atlas:write.label': 'Управление Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Все',
|
||||
'places.unplanned': 'Незапланированные',
|
||||
'places.filterTracks': 'Треки',
|
||||
'places.sortByRating': 'Сортировать по рейтингу',
|
||||
'places.yourRating': 'Твоя оценка',
|
||||
'places.notRated': 'Ещё нет оценок',
|
||||
'places.search': 'Поиск мест...',
|
||||
'places.allCategories': 'Все категории',
|
||||
'places.categoriesSelected': 'категорий',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Resor',
|
||||
'oauth.scope.group.places': 'Platser',
|
||||
'oauth.scope.group.collections': 'Samlingar',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Packning',
|
||||
'oauth.scope.group.todos': 'Att göra',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Läsa platser, dagsuppdrag, taggar och kategorier',
|
||||
'oauth.scope.places:write.label': 'Hantera platser',
|
||||
'oauth.scope.places:write.description': 'Skapa, uppdatera och ta bort platser, uppdrag och taggar',
|
||||
'oauth.scope.collections:read.label': 'Visa samlingar',
|
||||
'oauth.scope.collections:read.description': 'Läs samlingar med sparade platser, deras platser, betyg, etiketter och medlemmar',
|
||||
'oauth.scope.collections:write.label': 'Hantera samlingar',
|
||||
'oauth.scope.collections:write.description': 'Skapa/redigera samlingar, spara, betygsätt, etikettera och kopiera platser samt dela listor',
|
||||
'oauth.scope.atlas:read.label': 'Visa Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Läs om besökta länder, regioner och bucketlist',
|
||||
'oauth.scope.atlas:write.label': 'Hantera Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Alla',
|
||||
'places.unplanned': 'Oplanerat',
|
||||
'places.filterTracks': 'Spår',
|
||||
'places.sortByRating': 'Sortera efter betyg',
|
||||
'places.yourRating': 'Ditt betyg',
|
||||
'places.notRated': 'Inte betygsatt ännu',
|
||||
'places.search': 'Sök efter platser...',
|
||||
'places.allCategories': 'Alla kategorier',
|
||||
'places.categoriesSelected': 'kategorier',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Geziler',
|
||||
'oauth.scope.group.places': 'Yer',
|
||||
'oauth.scope.group.collections': 'Koleksiyonlar',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Ambalaj',
|
||||
'oauth.scope.group.todos': 'Yapılacaklar',
|
||||
@@ -27,6 +28,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Yerleri, gün atamalarını, etiketleri ve kategorileri oku',
|
||||
'oauth.scope.places:write.label': 'Yerleri yönet',
|
||||
'oauth.scope.places:write.description': 'Yerleri, atamaları ve etiketleri oluştur, güncelle ve sil',
|
||||
'oauth.scope.collections:read.label': 'Koleksiyonları görüntüle',
|
||||
'oauth.scope.collections:read.description': 'Kayıtlı yer koleksiyonlarını, içindeki yerleri, puanları, etiketleri ve üyeleri okuma',
|
||||
'oauth.scope.collections:write.label': 'Koleksiyonları yönet',
|
||||
'oauth.scope.collections:write.description': 'Koleksiyon oluşturma/düzenleme, yerleri kaydetme, puanlama, etiketleme ve kopyalama, listeleri paylaşma',
|
||||
'oauth.scope.atlas:read.label': "Atlas'ı Görüntüle",
|
||||
'oauth.scope.atlas:read.description': 'Ziyaret edilen ülkeleri, bölgeleri ve yapılacaklar listesini oku',
|
||||
'oauth.scope.atlas:write.label': "Atlas'ı Yönet",
|
||||
|
||||
@@ -45,6 +45,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Tüm',
|
||||
'places.unplanned': 'Planlanmamış',
|
||||
'places.filterTracks': 'Parçalar',
|
||||
'places.sortByRating': 'Puana göre sırala',
|
||||
'places.yourRating': 'Senin puanın',
|
||||
'places.notRated': 'Henüz puanlanmadı',
|
||||
'places.search': 'Yer ara...',
|
||||
'places.allCategories': 'Tüm Kategoriler',
|
||||
'places.categoriesSelected': 'kategoriler',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Поїздки',
|
||||
'oauth.scope.group.places': 'Місця',
|
||||
'oauth.scope.group.collections': 'Колекції',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Речі',
|
||||
'oauth.scope.group.todos': 'Задачі',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Читання місць, призначень за днями, тегів і категорій',
|
||||
'oauth.scope.places:write.label': 'Керування місцями',
|
||||
'oauth.scope.places:write.description': 'Створення, оновлення та видалення місць, призначень і тегів',
|
||||
'oauth.scope.collections:read.label': 'Перегляд колекцій',
|
||||
'oauth.scope.collections:read.description': 'Читання колекцій збережених місць, їхніх місць, оцінок, міток і учасників',
|
||||
'oauth.scope.collections:write.label': 'Керування колекціями',
|
||||
'oauth.scope.collections:write.description': 'Створення й редагування колекцій, збереження, оцінювання, додавання міток і копіювання місць, а також надання доступу до списків',
|
||||
'oauth.scope.atlas:read.label': 'Перегляд Atlas',
|
||||
'oauth.scope.atlas:read.description': 'Читання відвіданих країн, регіонів і списку бажань',
|
||||
'oauth.scope.atlas:write.label': 'Керування Atlas',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Усі',
|
||||
'places.unplanned': 'Незаплановані',
|
||||
'places.filterTracks': 'Треки',
|
||||
'places.sortByRating': 'Сортувати за рейтингом',
|
||||
'places.yourRating': 'Твоя оцінка',
|
||||
'places.notRated': 'Ще не оцінено',
|
||||
'places.search': 'Пошук місць...',
|
||||
'places.allCategories': 'Всі категорії',
|
||||
'places.categoriesSelected': 'категорій',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Chuyến đi',
|
||||
'oauth.scope.group.places': 'Địa điểm',
|
||||
'oauth.scope.group.collections': 'Bộ sưu tập',
|
||||
'oauth.scope.group.atlas': 'Bản đồ',
|
||||
'oauth.scope.group.packing': 'Đóng gói',
|
||||
'oauth.scope.group.todos': 'Việc cần làm',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': 'Đọc địa điểm, bài tập trong ngày, thẻ và danh mục',
|
||||
'oauth.scope.places:write.label': 'Quản lý địa điểm',
|
||||
'oauth.scope.places:write.description': 'Tạo, cập nhật và xóa địa điểm, bài tập và thẻ',
|
||||
'oauth.scope.collections:read.label': 'Xem bộ sưu tập',
|
||||
'oauth.scope.collections:read.description': 'Đọc bộ sưu tập địa điểm đã lưu cùng các địa điểm, đánh giá, nhãn và thành viên trong đó',
|
||||
'oauth.scope.collections:write.label': 'Quản lý bộ sưu tập',
|
||||
'oauth.scope.collections:write.description': 'Tạo/chỉnh sửa bộ sưu tập, lưu, đánh giá, gắn nhãn và sao chép địa điểm, chia sẻ danh sách',
|
||||
'oauth.scope.atlas:read.label': 'Xem bản đồ',
|
||||
'oauth.scope.atlas:read.description': 'Đọc các quốc gia, khu vực và danh sách nhóm đã ghé thăm',
|
||||
'oauth.scope.atlas:write.label': 'Quản lý bản đồ',
|
||||
|
||||
@@ -43,6 +43,9 @@ const places: TranslationStrings = {
|
||||
'places.all': 'Tất cả',
|
||||
'places.unplanned': 'Không có kế hoạch',
|
||||
'places.filterTracks': 'Bài hát',
|
||||
'places.sortByRating': 'Sắp xếp theo đánh giá',
|
||||
'places.yourRating': 'Đánh giá của bạn',
|
||||
'places.notRated': 'Chưa có đánh giá',
|
||||
'places.search': 'Tìm kiếm địa điểm...',
|
||||
'places.allCategories': 'Tất cả danh mục',
|
||||
'places.categoriesSelected': 'Thể loại',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': '行程',
|
||||
'oauth.scope.group.places': '地點',
|
||||
'oauth.scope.group.collections': '收藏集',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': '行李',
|
||||
'oauth.scope.group.todos': '待辦事項',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': '讀取地點、每日指派、標籤及類別',
|
||||
'oauth.scope.places:write.label': '管理地點',
|
||||
'oauth.scope.places:write.description': '建立、更新及刪除地點、指派及標籤',
|
||||
'oauth.scope.collections:read.label': '檢視收藏集',
|
||||
'oauth.scope.collections:read.description': '讀取地點收藏集及其中的地點、評分、標籤與成員',
|
||||
'oauth.scope.collections:write.label': '管理收藏集',
|
||||
'oauth.scope.collections:write.description': '建立/編輯收藏集,儲存、評分、標記及複製地點,並分享清單',
|
||||
'oauth.scope.atlas:read.label': '檢視 Atlas',
|
||||
'oauth.scope.atlas:read.description': '讀取已造訪的國家、地區及願望清單',
|
||||
'oauth.scope.atlas:write.label': '管理 Atlas',
|
||||
|
||||
@@ -42,6 +42,9 @@ const places: TranslationStrings = {
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未規劃',
|
||||
'places.filterTracks': '路線',
|
||||
'places.sortByRating': '依評分排序',
|
||||
'places.yourRating': '你的評分',
|
||||
'places.notRated': '尚未評分',
|
||||
'places.search': '搜尋地點...',
|
||||
'places.allCategories': '所有分類',
|
||||
'places.categoriesSelected': '個分類',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': '行程',
|
||||
'oauth.scope.group.places': '地点',
|
||||
'oauth.scope.group.collections': '收藏夹',
|
||||
'oauth.scope.group.atlas': '足迹',
|
||||
'oauth.scope.group.packing': '行李',
|
||||
'oauth.scope.group.todos': '待办事项',
|
||||
@@ -26,6 +27,10 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.places:read.description': '读取地点、每日分配、标签和分类',
|
||||
'oauth.scope.places:write.label': '管理地点',
|
||||
'oauth.scope.places:write.description': '创建、更新和删除地点、分配和标签',
|
||||
'oauth.scope.collections:read.label': '查看收藏夹',
|
||||
'oauth.scope.collections:read.description': '读取收藏夹及其中的地点、评分、标签和成员',
|
||||
'oauth.scope.collections:write.label': '管理收藏夹',
|
||||
'oauth.scope.collections:write.description': '创建/编辑收藏夹,保存、评分、标注、复制地点,以及分享列表',
|
||||
'oauth.scope.atlas:read.label': '查看足迹',
|
||||
'oauth.scope.atlas:read.description': '读取已访问国家、地区和心愿清单',
|
||||
'oauth.scope.atlas:write.label': '管理足迹',
|
||||
|
||||
@@ -42,6 +42,9 @@ const places: TranslationStrings = {
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未规划',
|
||||
'places.filterTracks': '路线',
|
||||
'places.sortByRating': '按评分排序',
|
||||
'places.yourRating': '你的评分',
|
||||
'places.notRated': '暂无评分',
|
||||
'places.search': '搜索地点...',
|
||||
'places.allCategories': '所有分类',
|
||||
'places.categoriesSelected': '个分类',
|
||||
|
||||
@@ -38,6 +38,15 @@ export type PlaceCategory = z.infer<typeof placeCategorySchema>;
|
||||
* projection and `tags` array. Numbers (lat/lng/price) are SQLite REAL, ids are
|
||||
* INTEGER; provider-derived columns are nullable.
|
||||
*/
|
||||
// One member's star vote on a place (#1435), with display info for the tooltip.
|
||||
export const placeRatingVoteSchema = z.object({
|
||||
user_id: z.number(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
rating: z.number(),
|
||||
});
|
||||
export type PlaceRatingVote = z.infer<typeof placeRatingVoteSchema>;
|
||||
|
||||
export const placeSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
@@ -68,6 +77,11 @@ export const placeSchema = z.object({
|
||||
updated_at: z.string().optional(),
|
||||
category: placeCategorySchema.optional(),
|
||||
tags: z.array(tagSchema.partial()).optional(),
|
||||
// Collaborative ratings (#1435): every member's vote (for the who-voted
|
||||
// tooltip) plus the aggregate the UI displays.
|
||||
ratings: z.array(placeRatingVoteSchema).optional(),
|
||||
rating_avg: z.number().nullable().optional(),
|
||||
rating_count: z.number().optional(),
|
||||
});
|
||||
export type Place = z.infer<typeof placeSchema>;
|
||||
|
||||
@@ -111,6 +125,12 @@ export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
|
||||
export const placeUpdateRequestSchema = open;
|
||||
export type PlaceUpdateRequest = z.infer<typeof placeUpdateRequestSchema>;
|
||||
|
||||
// Collaborative ratings (#1435): one 1-5 star vote per user and place.
|
||||
export const placeRatingRequestSchema = z.object({
|
||||
rating: z.number().int().min(1).max(5),
|
||||
});
|
||||
export type PlaceRatingRequest = z.infer<typeof placeRatingRequestSchema>;
|
||||
|
||||
export const placeBulkDeleteRequestSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user