mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(collections): per-member permission roles on shared lists (#1081)
The owner now assigns each member a role — viewer (read + copy-to-trip only), editor (default: add + edit places) or admin (full incl. delete). The owner is always full. Existing members default to editor via migration 152, so nothing regresses. - Server: role column on collection_members (migration 152 + schema); roleOf + assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) + POST members/role; members payload carries each role. - Client: Share modal gains a role picker on invite and a per-member role select for the owner (read-only role badge for others); the page hides add / edit / status / move / delete for roles that can't perform them (server still enforces). - Roles in all 22 locales; service + controller tests for the new gating.
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
CollectionRole,
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
@@ -76,8 +77,10 @@ export const collectionsApi = {
|
||||
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
|
||||
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
invite: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
|
||||
acceptInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
declineInvite: (collectionId: number): Promise<unknown> =>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface CollectionListProps {
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
onOpenPlace: (id: number) => void
|
||||
onStatusChange: (placeId: number, status: CollectionStatus) => void
|
||||
onStatusChange?: (placeId: number, status: CollectionStatus) => void
|
||||
onToggleSelect: (id: number) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export default function CollectionList({
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<StatusBadge status={place.status} onChange={selectMode ? undefined : next => onStatusChange(place.id, next)} t={t} />
|
||||
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ function linkHost(url: string): string {
|
||||
interface CollectionPlaceDetailProps {
|
||||
place: CollectionPlace
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
@@ -55,7 +56,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -139,8 +140,8 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — always live */}
|
||||
<StatusSegment status={place.status} onSet={onSetStatus} t={t} />
|
||||
{/* Status — live for editors, read-only for viewers */}
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
@@ -212,7 +213,7 @@ export default function CollectionPlaceDetail({
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canEdit && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { UserPlus, UserMinus, Loader2, Clock, Check, Crown, LogOut } from 'lucide-react'
|
||||
import type { CollectionMember } from '@trek/shared'
|
||||
import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react'
|
||||
import type { CollectionMember, CollectionRole } from '@trek/shared'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -22,6 +22,8 @@ interface ShareCollectionModalProps {
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin']
|
||||
|
||||
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
|
||||
const initial = (member.username || '?').charAt(0).toUpperCase()
|
||||
return (
|
||||
@@ -57,10 +59,13 @@ export default function ShareCollectionModal({
|
||||
const invite = useCollectionStore(s => s.invite)
|
||||
const cancelInvite = useCollectionStore(s => s.cancelInvite)
|
||||
const removeMember = useCollectionStore(s => s.removeMember)
|
||||
const setMemberRole = useCollectionStore(s => s.setMemberRole)
|
||||
const leave = useCollectionStore(s => s.leave)
|
||||
|
||||
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
|
||||
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
|
||||
const [settingRoleId, setSettingRoleId] = useState<number | null>(null)
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null)
|
||||
const [removingId, setRemovingId] = useState<number | null>(null)
|
||||
@@ -97,7 +102,7 @@ export default function ShareCollectionModal({
|
||||
if (selectedUserId === '' || inviting) return
|
||||
setInviting(true)
|
||||
try {
|
||||
await invite(collectionId, Number(selectedUserId))
|
||||
await invite(collectionId, Number(selectedUserId), inviteRole)
|
||||
toast.success(t('collections.invite.sent'))
|
||||
setSelectedUserId('')
|
||||
} catch (err) {
|
||||
@@ -107,6 +112,17 @@ export default function ShareCollectionModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetRole = async (userId: number, role: CollectionRole) => {
|
||||
setSettingRoleId(userId)
|
||||
try {
|
||||
await setMemberRole(collectionId, userId, role)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSettingRoleId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (userId: number) => {
|
||||
if (cancellingId != null) return
|
||||
setCancellingId(userId)
|
||||
@@ -185,9 +201,19 @@ export default function ShareCollectionModal({
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
|
||||
<Clock size={11} /> {t('collections.share.pending')}
|
||||
</span>
|
||||
) : isOwner ? (
|
||||
<select
|
||||
value={member.role ?? 'editor'}
|
||||
onChange={e => handleSetRole(member.user_id, e.target.value as CollectionRole)}
|
||||
disabled={settingRoleId === member.user_id}
|
||||
aria-label={t('collections.role.label')}
|
||||
className="shrink-0 text-[11.5px] font-medium rounded-md border border-edge bg-surface-input text-content-secondary px-1.5 py-1 outline-none focus:border-accent disabled:opacity-50"
|
||||
>
|
||||
{ROLE_ORDER.map(r => <option key={r} value={r}>{t(`collections.role.${r}`)}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-emerald-500/12 text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<Check size={11} /> {t('collections.share.accepted')}
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-surface-secondary text-content-secondary shrink-0">
|
||||
{t(`collections.role.${member.role ?? 'editor'}`)}
|
||||
</span>
|
||||
)}
|
||||
{isOwner && pending && (
|
||||
@@ -238,6 +264,14 @@ export default function ShareCollectionModal({
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={e => setInviteRole(e.target.value as CollectionRole)}
|
||||
aria-label={t('collections.role.label')}
|
||||
className="shrink-0 text-[12.5px] font-medium rounded-lg border border-edge bg-surface-input text-content-secondary px-2 outline-none focus:border-accent"
|
||||
>
|
||||
{ROLE_ORDER.map(r => <option key={r} value={r}>{t(`collections.role.${r}`)}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInvite}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const desktopSplit = c.isWide && mappable.length > 0
|
||||
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
|
||||
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
|
||||
const canAddPlace = typeof c.activeId === 'number' // a real list, not "All saved"
|
||||
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
|
||||
|
||||
const listEl = (
|
||||
<CollectionList
|
||||
@@ -70,7 +70,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
selectMode={c.selectMode}
|
||||
selectedIds={c.selectedIds}
|
||||
onOpenPlace={openPlace}
|
||||
onStatusChange={c.handleStatusChange}
|
||||
onStatusChange={c.canEdit ? c.handleStatusChange : undefined}
|
||||
onToggleSelect={c.toggleSelect}
|
||||
t={t}
|
||||
/>
|
||||
@@ -234,18 +234,22 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
</button>
|
||||
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
|
||||
<div className="col-toolbar-spacer" />
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
</button>
|
||||
{c.canEdit && (
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<CopyPlus size={14} /> {t('collections.duplicateToList')}
|
||||
</button>
|
||||
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<Copy size={14} /> {t('collections.copyToTrip')}
|
||||
</button>
|
||||
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
|
||||
<Trash2 size={14} /> {t('common.delete')}
|
||||
</button>
|
||||
{c.canDelete && (
|
||||
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
|
||||
<Trash2 size={14} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
@@ -278,7 +282,8 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
{c.selectedPlace && c.view !== 'map' && (
|
||||
<CollectionPlaceDetail
|
||||
place={c.selectedPlace}
|
||||
canEdit
|
||||
canEdit={c.canEdit}
|
||||
canDelete={c.canDelete}
|
||||
categories={c.categories}
|
||||
anchorRect={desktopSplit ? c.listColRect : null}
|
||||
onClose={c.handleCloseDetail}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { useElementRect } from '../../hooks/useElementRect'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
@@ -139,6 +140,18 @@ export function useCollections() {
|
||||
const isAllSaved = activeId === ALL_SAVED
|
||||
const isOwner = activeCollection?.is_owner ?? false
|
||||
|
||||
// The current user's permission on the active list drives what the UI offers.
|
||||
// "All saved" spans many lists (each enforced per-place on the server), so it
|
||||
// stays permissive on the client; a viewer of a shared list loses add/edit/delete.
|
||||
const currentUserId = useAuthStore(s => s.user?.id)
|
||||
const myRole = useMemo(() => {
|
||||
if (isOwner) return 'owner' as const
|
||||
const me = members.find(m => m.user_id === currentUserId && !m.is_owner)
|
||||
return me?.role ?? null
|
||||
}, [isOwner, members, currentUserId])
|
||||
const canEdit = isAllSaved || myRole === 'owner' || myRole === 'admin' || myRole === 'editor'
|
||||
const canDelete = isAllSaved || myRole === 'owner' || myRole === 'admin'
|
||||
|
||||
// Sharing is offered on any real (non "All saved") list the user can see. The
|
||||
// badge counts everyone but the owner — accepted collaborators + pending invites.
|
||||
const canShare = typeof activeId === 'number' && activeCollection != null
|
||||
@@ -320,6 +333,7 @@ export function useCollections() {
|
||||
listColRef: listCol.ref, listColRect: listCol.rect, categories,
|
||||
// store data
|
||||
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
|
||||
myRole, canEdit, canDelete,
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CollectionPlace,
|
||||
CollectionMember,
|
||||
CollectionStatus,
|
||||
CollectionRole,
|
||||
CollectionListResponse,
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
@@ -56,7 +57,8 @@ interface CollectionState {
|
||||
moveToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
|
||||
invite: (collectionId: number, userId: number) => Promise<void>
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
|
||||
acceptInvite: (collectionId: number) => Promise<void>
|
||||
declineInvite: (collectionId: number) => Promise<void>
|
||||
cancelInvite: (collectionId: number, userId: number) => Promise<void>
|
||||
@@ -275,8 +277,13 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
invite: async (collectionId: number, userId: number) => {
|
||||
await collectionsApi.invite(collectionId, userId)
|
||||
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
|
||||
await collectionsApi.invite(collectionId, userId, role)
|
||||
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
||||
},
|
||||
|
||||
setMemberRole: async (collectionId: number, userId: number, role: CollectionRole) => {
|
||||
await collectionsApi.setMemberRole(collectionId, userId, role)
|
||||
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
||||
},
|
||||
|
||||
|
||||
@@ -3261,6 +3261,11 @@ function runMigrations(db: Database.Database): void {
|
||||
try { db.exec('ALTER TABLE collections ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
try { db.exec('ALTER TABLE collection_places ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
// Migration 152: per-member permission role on a shared list. Existing
|
||||
// accepted members default to 'editor' so nothing regresses.
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE collection_members ADD COLUMN role TEXT NOT NULL DEFAULT 'editor'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -391,6 +391,7 @@ function createTables(db: Database.Database): void {
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, user_id)
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
collectionInviteActionRequestSchema,
|
||||
collectionInviteCancelRequestSchema,
|
||||
collectionRemoveMemberRequestSchema,
|
||||
collectionSetMemberRoleRequestSchema,
|
||||
type CollectionCreateRequest,
|
||||
type CollectionUpdateRequest,
|
||||
type CollectionSavePlaceRequest,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
type CollectionInviteActionRequest,
|
||||
type CollectionInviteCancelRequest,
|
||||
type CollectionRemoveMemberRequest,
|
||||
type CollectionSetMemberRoleRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
const MAX_COVER_SIZE = 20 * 1024 * 1024;
|
||||
@@ -190,7 +192,7 @@ export class CollectionsController {
|
||||
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
||||
throw new HttpException({ error: 'Only the owner can invite' }, 403);
|
||||
}
|
||||
const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id);
|
||||
const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
@@ -243,6 +245,17 @@ export class CollectionsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('members/role')
|
||||
@HttpCode(200)
|
||||
setMemberRole(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSetMemberRoleRequestSchema)) body: CollectionSetMemberRoleRequest) {
|
||||
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible
|
||||
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
||||
throw new HttpException({ error: 'Only the owner can change member roles' }, 403);
|
||||
}
|
||||
this.collections.setMemberRole(user.id, body.collection_id, body.user_id, body.role);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── /:id (declared last so static prefixes win) ─────────────────────────────
|
||||
@Get(':id/available-users')
|
||||
availableUsers(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
|
||||
@@ -42,13 +42,14 @@ export class CollectionsService {
|
||||
assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); }
|
||||
isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); }
|
||||
|
||||
sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number) {
|
||||
return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId);
|
||||
sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, role?: 'viewer' | 'editor' | 'admin') {
|
||||
return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId, role);
|
||||
}
|
||||
acceptInvite(userId: number, collectionId: number, socketId?: string) { return svc.acceptInvite(userId, collectionId, socketId); }
|
||||
declineInvite(userId: number, collectionId: number, socketId?: string) { return svc.declineInvite(userId, collectionId, socketId); }
|
||||
cancelInvite(collectionId: number, ownerId: number, targetUserId: number) { return svc.cancelInvite(collectionId, ownerId, targetUserId); }
|
||||
leaveCollection(userId: number, collectionId: number, socketId?: string) { return svc.leaveCollection(userId, collectionId, socketId); }
|
||||
removeMember(ownerId: number, collectionId: number, targetUserId: number) { return svc.removeMember(ownerId, collectionId, targetUserId); }
|
||||
setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin') { return svc.setMemberRole(ownerId, collectionId, targetUserId, role); }
|
||||
availableUsers(ownerId: number, collectionId: number) { return svc.availableUsers(ownerId, collectionId); }
|
||||
}
|
||||
|
||||
@@ -90,6 +90,30 @@ export function isOwner(userId: number, collectionId: number): boolean {
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export type EffectiveRole = 'owner' | 'admin' | 'editor' | 'viewer' | null;
|
||||
/** The viewer's effective permission on a list: owner (full), or their accepted
|
||||
* member role, or null when they have no access. */
|
||||
export function roleOf(userId: number, collectionId: number): EffectiveRole {
|
||||
if (isOwner(userId, collectionId)) return 'owner';
|
||||
const row = db.prepare("SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'")
|
||||
.get(collectionId, userId) as { role: string } | undefined;
|
||||
if (!row) return null;
|
||||
return row.role === 'admin' || row.role === 'viewer' ? row.role : 'editor';
|
||||
}
|
||||
/** Add/edit a place — owner, admin or editor. 404 hides lists you can't see,
|
||||
* 403 for a read-only (viewer) member. */
|
||||
export function assertCanEdit(userId: number, collectionId: number): void {
|
||||
const r = roleOf(userId, collectionId);
|
||||
if (r === null) httpError(404, 'Collection not found');
|
||||
if (r === 'viewer') httpError(403, 'You have read-only access to this list');
|
||||
}
|
||||
/** Delete a place — owner or admin only. */
|
||||
export function assertCanDelete(userId: number, collectionId: number): void {
|
||||
const r = roleOf(userId, collectionId);
|
||||
if (r === null) httpError(404, 'Collection not found');
|
||||
if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list');
|
||||
}
|
||||
|
||||
function ownerOf(collectionId: number): number {
|
||||
const row = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined;
|
||||
if (!row) httpError(404, 'Collection not found');
|
||||
@@ -163,13 +187,13 @@ function buildMembers(collectionId: number): CollectionMember[] {
|
||||
WHERE col.id = ?
|
||||
`).get(collectionId) as Omit<CollectionMember, 'status' | 'is_owner'> | undefined;
|
||||
const members = db.prepare(`
|
||||
SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status
|
||||
SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role
|
||||
FROM collection_members cm JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.collection_id = ?
|
||||
ORDER BY u.username
|
||||
`).all(collectionId) as Omit<CollectionMember, 'is_owner'>[];
|
||||
const result: CollectionMember[] = [];
|
||||
if (owner) result.push({ ...owner, status: 'accepted', is_owner: true });
|
||||
if (owner) result.push({ ...owner, status: 'accepted', role: 'admin', is_owner: true });
|
||||
for (const m of members) result.push({ ...m, is_owner: false });
|
||||
return result;
|
||||
}
|
||||
@@ -239,7 +263,7 @@ export function createCollection(userId: number, body: CollectionCreateRequest):
|
||||
}
|
||||
|
||||
export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection {
|
||||
assertAccess(userId, id);
|
||||
assertCanEdit(userId, id);
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
|
||||
@@ -261,7 +285,7 @@ export function updateCollection(userId: number, id: number, body: CollectionUpd
|
||||
|
||||
/** Set (or clear) a list's cover image, reclaiming the previous file. */
|
||||
export function setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Collection {
|
||||
assertAccess(userId, id);
|
||||
assertCanEdit(userId, id);
|
||||
const prev = (db.prepare('SELECT cover_image FROM collections WHERE id = ?').get(id) as { cover_image: string | null } | undefined)?.cover_image ?? null;
|
||||
db.prepare('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(coverUrl, id);
|
||||
if (prev && prev !== coverUrl) deleteOldCollectionCover(prev);
|
||||
@@ -367,7 +391,7 @@ function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): vo
|
||||
}
|
||||
|
||||
export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult {
|
||||
assertAccess(userId, body.collection_id);
|
||||
assertCanEdit(userId, body.collection_id);
|
||||
|
||||
if (!body.force) {
|
||||
const dup = findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng });
|
||||
@@ -400,7 +424,7 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest, sock
|
||||
export function saveFromTripPlace(
|
||||
userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean,
|
||||
): CollectionSaveResult {
|
||||
assertAccess(userId, collectionId);
|
||||
assertCanEdit(userId, collectionId);
|
||||
if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
|
||||
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
|
||||
@@ -431,7 +455,7 @@ export function saveFromTripPlace(
|
||||
|
||||
export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace {
|
||||
const currentCollection = collectionIdOfPlace(placeId);
|
||||
assertAccess(userId, currentCollection);
|
||||
assertCanEdit(userId, currentCollection);
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
@@ -444,7 +468,7 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek
|
||||
|
||||
let movedTo: number | null = null;
|
||||
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
|
||||
assertAccess(userId, body.collection_id);
|
||||
assertCanEdit(userId, body.collection_id);
|
||||
updates.push('collection_id = ?'); params.push(body.collection_id);
|
||||
updates.push('owner_id = ?'); params.push(ownerOf(body.collection_id));
|
||||
movedTo = body.collection_id;
|
||||
@@ -468,7 +492,7 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek
|
||||
|
||||
export function setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace {
|
||||
const collectionId = collectionIdOfPlace(placeId);
|
||||
assertAccess(userId, collectionId);
|
||||
assertCanEdit(userId, collectionId);
|
||||
db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId);
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
return getPlaceById(placeId);
|
||||
@@ -476,7 +500,7 @@ export function setStatus(userId: number, placeId: number, status: CollectionSta
|
||||
|
||||
export function deletePlace(userId: number, placeId: number, socketId?: string): void {
|
||||
const collectionId = collectionIdOfPlace(placeId);
|
||||
assertAccess(userId, collectionId);
|
||||
assertCanDelete(userId, collectionId);
|
||||
db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim.
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
}
|
||||
@@ -486,7 +510,7 @@ export function deletePlacesMany(userId: number, ids: number[], socketId?: strin
|
||||
const touched = new Set<number>();
|
||||
for (const id of ids) {
|
||||
const collectionId = collectionIdOfPlace(id);
|
||||
assertAccess(userId, collectionId);
|
||||
assertCanDelete(userId, collectionId);
|
||||
db.prepare('DELETE FROM collection_places WHERE id = ?').run(id);
|
||||
deleted.push(id);
|
||||
touched.add(collectionId);
|
||||
@@ -616,6 +640,7 @@ export function notifyCollectionUsers(collectionId: number, excludeSid: string |
|
||||
|
||||
export function sendInvite(
|
||||
collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number,
|
||||
role: 'viewer' | 'editor' | 'admin' = 'editor',
|
||||
): { error?: string; status?: number } {
|
||||
if (!isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 };
|
||||
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
|
||||
@@ -629,7 +654,7 @@ export function sendInvite(
|
||||
if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 };
|
||||
}
|
||||
|
||||
db.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'pending')").run(collectionId, targetUserId);
|
||||
db.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)").run(collectionId, targetUserId, role);
|
||||
|
||||
broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId });
|
||||
|
||||
@@ -675,6 +700,15 @@ export function removeMember(ownerId: number, collectionId: number, targetUserId
|
||||
broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user
|
||||
}
|
||||
|
||||
/** Owner changes an accepted member's permission role (viewer/editor/admin). */
|
||||
export function setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): void {
|
||||
if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed');
|
||||
const res = db.prepare("UPDATE collection_members SET role = ? WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(role, collectionId, targetUserId);
|
||||
if (res.changes === 0) httpError(404, 'Member not found');
|
||||
notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live
|
||||
broadcastToUser(targetUserId, { type: 'collections:updated', collectionId });
|
||||
}
|
||||
|
||||
export function availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.username FROM users u
|
||||
|
||||
@@ -6,6 +6,13 @@ export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const;
|
||||
export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea');
|
||||
export type CollectionStatus = (typeof COLLECTION_STATUSES)[number];
|
||||
|
||||
/** Per-member permission on a shared list. viewer = read + copy-to-trip only;
|
||||
* editor (default) = add + edit places; admin = full incl. delete. The owner
|
||||
* is always full and is not a member row. */
|
||||
export const COLLECTION_ROLES = ['viewer', 'editor', 'admin'] as const;
|
||||
export const collectionRoleSchema = z.enum(COLLECTION_ROLES).catch('editor').default('editor');
|
||||
export type CollectionRole = (typeof COLLECTION_ROLES)[number];
|
||||
|
||||
/** A user-added link on a list or a saved place (stored as a JSON array). */
|
||||
export const collectionLinkSchema = z.object({
|
||||
label: z.string().max(120).optional(),
|
||||
@@ -55,6 +62,7 @@ export const collectionMemberSchema = z.object({
|
||||
email: z.string().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
status: z.enum(['pending', 'accepted']),
|
||||
role: collectionRoleSchema.optional(),
|
||||
is_owner: z.boolean().optional(),
|
||||
});
|
||||
export type CollectionMember = z.infer<typeof collectionMemberSchema>;
|
||||
@@ -157,6 +165,7 @@ export type CollectionCopyToTripRequest = z.infer<typeof collectionCopyToTripReq
|
||||
export const collectionInviteRequestSchema = z.object({
|
||||
collection_id: z.number(),
|
||||
user_id: z.number(),
|
||||
role: collectionRoleSchema.optional(),
|
||||
});
|
||||
export type CollectionInviteRequest = z.infer<typeof collectionInviteRequestSchema>;
|
||||
|
||||
@@ -176,6 +185,14 @@ export const collectionRemoveMemberRequestSchema = z.object({
|
||||
});
|
||||
export type CollectionRemoveMemberRequest = z.infer<typeof collectionRemoveMemberRequestSchema>;
|
||||
|
||||
/** Owner changes an accepted member's permission role. */
|
||||
export const collectionSetMemberRoleRequestSchema = z.object({
|
||||
collection_id: z.number(),
|
||||
user_id: z.number(),
|
||||
role: collectionRoleSchema,
|
||||
});
|
||||
export type CollectionSetMemberRoleRequest = z.infer<typeof collectionSetMemberRoleRequestSchema>;
|
||||
|
||||
// ── Responses ─────────────────────────────────────────────────────────────
|
||||
export const collectionListResponseSchema = z.object({
|
||||
collections: z.array(collectionSchema),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'الدور',
|
||||
'collections.role.viewer': 'مشاهد',
|
||||
'collections.role.editor': 'محرر',
|
||||
'collections.role.admin': 'مشرف',
|
||||
'collections.picker.allLists': 'كل القوائم',
|
||||
'collections.select': 'تحديد',
|
||||
'collections.selectAll': 'تحديد الكل',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Função',
|
||||
'collections.role.viewer': 'Visualizador',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.admin': 'Administrador',
|
||||
'collections.picker.allLists': 'Todas as listas',
|
||||
'collections.select': 'Selecionar',
|
||||
'collections.selectAll': 'Selecionar tudo',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Role',
|
||||
'collections.role.viewer': 'Divák',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.admin': 'Správce',
|
||||
'collections.picker.allLists': 'Všechny seznamy',
|
||||
'collections.select': 'Vybrat',
|
||||
'collections.selectAll': 'Vybrat vše',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rolle',
|
||||
'collections.role.viewer': 'Betrachter',
|
||||
'collections.role.editor': 'Bearbeiter',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'Alle Listen',
|
||||
'collections.select': 'Auswählen',
|
||||
'collections.selectAll': 'Alle auswählen',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Role',
|
||||
'collections.role.viewer': 'Viewer',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'All lists',
|
||||
'collections.select': 'Select',
|
||||
'collections.selectAll': 'Select all',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rol',
|
||||
'collections.role.viewer': 'Lector',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.admin': 'Administrador',
|
||||
'collections.picker.allLists': 'Todas las listas',
|
||||
'collections.select': 'Seleccionar',
|
||||
'collections.selectAll': 'Seleccionar todo',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rôle',
|
||||
'collections.role.viewer': 'Lecteur',
|
||||
'collections.role.editor': 'Éditeur',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'Toutes les listes',
|
||||
'collections.select': 'Sélectionner',
|
||||
'collections.selectAll': 'Tout sélectionner',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Ρόλος',
|
||||
'collections.role.viewer': 'Θεατής',
|
||||
'collections.role.editor': 'Συντάκτης',
|
||||
'collections.role.admin': 'Διαχειριστής',
|
||||
'collections.picker.allLists': 'Όλες οι λίστες',
|
||||
'collections.select': 'Επιλογή',
|
||||
'collections.selectAll': 'Επιλογή όλων',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Szerep',
|
||||
'collections.role.viewer': 'Néző',
|
||||
'collections.role.editor': 'Szerkesztő',
|
||||
'collections.role.admin': 'Adminisztrátor',
|
||||
'collections.picker.allLists': 'Összes lista',
|
||||
'collections.select': 'Kijelölés',
|
||||
'collections.selectAll': 'Összes kijelölése',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Peran',
|
||||
'collections.role.viewer': 'Peninjau',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'Semua daftar',
|
||||
'collections.select': 'Pilih',
|
||||
'collections.selectAll': 'Pilih semua',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Ruolo',
|
||||
'collections.role.viewer': 'Visualizzatore',
|
||||
'collections.role.editor': 'Editore',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'Tutte le liste',
|
||||
'collections.select': 'Seleziona',
|
||||
'collections.selectAll': 'Seleziona tutto',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': '役割',
|
||||
'collections.role.viewer': '閲覧者',
|
||||
'collections.role.editor': '編集者',
|
||||
'collections.role.admin': '管理者',
|
||||
'collections.picker.allLists': 'すべてのリスト',
|
||||
'collections.select': '選択',
|
||||
'collections.selectAll': 'すべて選択',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': '역할',
|
||||
'collections.role.viewer': '뷰어',
|
||||
'collections.role.editor': '편집자',
|
||||
'collections.role.admin': '관리자',
|
||||
'collections.picker.allLists': '모든 목록',
|
||||
'collections.select': '선택',
|
||||
'collections.selectAll': '모두 선택',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rol',
|
||||
'collections.role.viewer': 'Kijker',
|
||||
'collections.role.editor': 'Bewerker',
|
||||
'collections.role.admin': 'Beheerder',
|
||||
'collections.picker.allLists': 'Alle lijsten',
|
||||
'collections.select': 'Selecteren',
|
||||
'collections.selectAll': 'Alles selecteren',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rola',
|
||||
'collections.role.viewer': 'Widz',
|
||||
'collections.role.editor': 'Edytor',
|
||||
'collections.role.admin': 'Administrator',
|
||||
'collections.picker.allLists': 'Wszystkie listy',
|
||||
'collections.select': 'Zaznacz',
|
||||
'collections.selectAll': 'Zaznacz wszystko',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Роль',
|
||||
'collections.role.viewer': 'Наблюдатель',
|
||||
'collections.role.editor': 'Редактор',
|
||||
'collections.role.admin': 'Админ',
|
||||
'collections.picker.allLists': 'Все списки',
|
||||
'collections.select': 'Выбрать',
|
||||
'collections.selectAll': 'Выбрать все',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Roll',
|
||||
'collections.role.viewer': 'Läsare',
|
||||
'collections.role.editor': 'Redigerare',
|
||||
'collections.role.admin': 'Admin',
|
||||
'collections.picker.allLists': 'Alla listor',
|
||||
'collections.select': 'Välj',
|
||||
'collections.selectAll': 'Välj alla',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Rol',
|
||||
'collections.role.viewer': 'Görüntüleyici',
|
||||
'collections.role.editor': 'Editör',
|
||||
'collections.role.admin': 'Yönetici',
|
||||
'collections.picker.allLists': 'Tüm listeler',
|
||||
'collections.select': 'Seç',
|
||||
'collections.selectAll': 'Tümünü seç',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Роль',
|
||||
'collections.role.viewer': 'Глядач',
|
||||
'collections.role.editor': 'Редактор',
|
||||
'collections.role.admin': 'Адмін',
|
||||
'collections.picker.allLists': 'Усі списки',
|
||||
'collections.select': 'Вибрати',
|
||||
'collections.selectAll': 'Вибрати все',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': 'Vai trò',
|
||||
'collections.role.viewer': 'Người xem',
|
||||
'collections.role.editor': 'Người sửa',
|
||||
'collections.role.admin': 'Quản trị',
|
||||
'collections.picker.allLists': 'Tất cả danh sách',
|
||||
'collections.select': 'Chọn',
|
||||
'collections.selectAll': 'Chọn tất cả',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': '角色',
|
||||
'collections.role.viewer': '檢視者',
|
||||
'collections.role.editor': '編輯者',
|
||||
'collections.role.admin': '管理員',
|
||||
'collections.picker.allLists': '所有清單',
|
||||
'collections.select': '選擇',
|
||||
'collections.selectAll': '全選',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.role.label': '角色',
|
||||
'collections.role.viewer': '查看者',
|
||||
'collections.role.editor': '编辑者',
|
||||
'collections.role.admin': '管理员',
|
||||
'collections.picker.allLists': '所有列表',
|
||||
'collections.select': '选择',
|
||||
'collections.selectAll': '全选',
|
||||
|
||||
Reference in New Issue
Block a user