1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 05:05:17 +02:00

Collection invite system

This commit is contained in:
Sean Morley 2025-07-30 22:34:07 -04:00
parent 7f5b969dbf
commit 5e8c485220
17 changed files with 1057 additions and 157 deletions

View file

@ -20,6 +20,7 @@
import DeleteWarning from './DeleteWarning.svelte';
import ShareModal from './ShareModal.svelte';
import CardCarousel from './CardCarousel.svelte';
import ExitRun from '~icons/mdi/exit-run';
const dispatch = createEventDispatcher();
@ -245,6 +246,26 @@
{/if}
</ul>
</div>
{:else if user && collection.shared_with && collection.shared_with.includes(user.uuid)}
<!-- dropdown with leave button -->
<div class="dropdown dropdown-end">
<button type="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
</button>
<ul
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-xl border border-base-300"
>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => dispatch('leave', collection.id)}
>
<ExitRun class="w-4 h-4" />
{$t('adventures.leave_collection')}
</button>
</li>
</ul>
</div>
{/if}
</div>
{/if}

View file

@ -177,7 +177,7 @@
<div class="stat py-2 px-4">
<div class="stat-title text-xs flex items-center gap-1">
<Share class="w-3 h-3" />
{$t('collection.shared')}
{$t('share.shared')}
</div>
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
</div>
@ -224,7 +224,7 @@
{#if loading}
<div class="flex flex-col items-center justify-center py-16">
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
<p class="text-base-content/60">{$t('loading.collections')}</p>
<p class="text-base-content/60">{$t('adventures.loading_collections')}</p>
</div>
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
<div class="flex flex-col items-center justify-center py-16">
@ -260,7 +260,7 @@
<div class="flex items-center gap-2 mb-4">
<Collections class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold text-base-content">
{$t('collection.my_collections')}
{$t('adventures.my_collections')}
</h2>
<div class="badge badge-primary badge-sm">
{filteredOwnCollections.length}
@ -287,7 +287,7 @@
<div class="flex items-center gap-2 mb-4">
<Share class="w-5 h-5 text-warning" />
<h2 class="text-lg font-semibold text-base-content">
{$t('collection.shared_with_me')}
{$t('navbar.shared_with_me')}
</h2>
<div class="badge badge-warning badge-sm">
{filteredSharedCollections.length}
@ -308,7 +308,7 @@
<div class="absolute -top-2 -right-2 z-10">
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
<Share class="w-3 h-3" />
{$t('collection.shared')}
{$t('share.shared')}
</div>
</div>
</div>

View file

@ -1,47 +1,201 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { t } from 'svelte-i18n';
// Icons
import AlertTriangle from '~icons/mdi/alert';
import HelpCircle from '~icons/mdi/help-circle';
import InfoCircle from '~icons/mdi/information';
import Close from '~icons/mdi/close';
import Check from '~icons/mdi/check';
import Cancel from '~icons/mdi/cancel';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let isVisible = false;
export let title: string;
export let button_text: string;
export let description: string;
export let is_warning: boolean;
export let is_warning: boolean = false;
$: modalType = is_warning ? 'warning' : 'info';
$: iconComponent = is_warning ? AlertTriangle : HelpCircle;
$: colorScheme = getColorScheme(modalType);
function getColorScheme(type: string) {
switch (type) {
case 'warning':
return {
icon: 'text-warning',
iconBg: 'bg-warning/10',
border: 'border-warning/20',
button: 'btn-warning',
backdrop: 'bg-warning/5'
};
default:
return {
icon: 'text-info',
iconBg: 'bg-info/10',
border: 'border-info/20',
button: 'btn-primary',
backdrop: 'bg-info/5'
};
}
}
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal = document.getElementById('confirmation_modal') as HTMLDialogElement;
if (modal) {
modal.showModal();
setTimeout(() => (isVisible = true), 50);
}
});
function close() {
dispatch('close');
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
}, 150);
}
function confirm() {
dispatch('close');
dispatch('confirm');
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
dispatch('confirm');
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
close();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === modal) {
close();
}
}
</script>
<dialog id="my_modal_1" class="modal {is_warning ? 'bg-primary' : ''}">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{title}</h3>
<p class="py-1 mb-4">{description}</p>
<button class="btn btn-{is_warning ? 'warning' : 'primary'} mr-2" on:click={confirm}
>{button_text}</button
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog
id="confirmation_modal"
class="modal backdrop-blur-sm"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
>
{#if isVisible}
<div
class="modal-box max-w-md relative overflow-hidden border-2 {colorScheme.border} bg-base-100/95 backdrop-blur-lg shadow-2xl"
transition:scale={{ duration: 150, easing: quintOut, start: 0.1 }}
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<button class="btn btn-neutral" on:click={close}>{$t('adventures.cancel')}</button>
</div>
<!-- Close button -->
<button
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 hover:bg-base-content/10 transition-colors"
on:click={close}
aria-label="Close modal"
>
<Close class="w-4 h-4" />
</button>
<!-- Content -->
<div class="flex flex-col items-center text-center pt-6 pb-2">
<!-- Icon -->
<div
class="w-16 h-16 rounded-full {colorScheme.iconBg} flex items-center justify-center mb-6 ring-4 ring-base-300/20"
>
<svelte:component this={iconComponent} class="w-8 h-8 {colorScheme.icon}" />
</div>
<!-- Title -->
<h3 id="modal-title" class="text-2xl font-bold text-base-content mb-3">
{title}
</h3>
<!-- Description -->
<p id="modal-description" class="text-base-content/70 leading-relaxed mb-8 max-w-sm">
{description}
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button
class="btn {colorScheme.button} flex-1 gap-2 shadow-lg hover:shadow-xl transition-all duration-200"
on:click={confirm}
>
<Check class="w-4 h-4" />
{button_text}
</button>
<button
class="btn btn-neutral-200 flex-1 gap-2 hover:bg-base-content/10 transition-colors"
on:click={close}
>
<Cancel class="w-4 h-4" />
{$t('adventures.cancel')}
</button>
</div>
<!-- Subtle gradient overlay for depth -->
<div
class="absolute inset-0 bg-gradient-to-br from-white/5 via-transparent to-black/5 pointer-events-none"
></div>
<!-- Decorative elements -->
<div
class="absolute -top-20 -right-20 w-40 h-40 {colorScheme.iconBg} rounded-full opacity-20 blur-3xl"
></div>
<div
class="absolute -bottom-10 -left-10 w-32 h-32 {colorScheme.iconBg} rounded-full opacity-10 blur-2xl"
></div>
</div>
<!-- Enhanced backdrop -->
<div
class="fixed inset-0 {colorScheme.backdrop} -z-10"
transition:fade={{ duration: 200 }}
></div>
{/if}
</dialog>
<style>
/* Ensure modal appears above everything */
dialog {
z-index: 9999;
}
/* Custom backdrop blur effect */
dialog::backdrop {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.3);
}
/* Smooth modal entrance */
.modal-box {
transform-origin: center;
}
/* Enhanced button hover effects */
.btn:hover {
transform: translateY(-1px);
}
/* Focus styles for accessibility */
.btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>

View file

@ -8,14 +8,22 @@
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import Share from '~icons/mdi/share';
import Clear from '~icons/mdi/close';
export let collection: Collection;
let allUsers: User[] = [];
// Extended user interface to include status
interface UserWithStatus extends User {
status?: 'available' | 'pending';
}
let sharedWithUsers: User[] = [];
let notSharedWithUsers: User[] = [];
let allUsers: UserWithStatus[] = [];
let sharedWithUsers: UserWithStatus[] = [];
let notSharedWithUsers: UserWithStatus[] = [];
async function share(user: User) {
// Send invite to user
async function sendInvite(user: User) {
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
method: 'POST',
headers: {
@ -23,20 +31,20 @@
}
});
if (res.ok) {
sharedWithUsers = sharedWithUsers.concat(user);
if (collection.shared_with) {
collection.shared_with.push(user.uuid);
} else {
collection.shared_with = [user.uuid];
// Update user status to pending
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
if (userIndex !== -1) {
notSharedWithUsers[userIndex].status = 'pending';
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
}
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
`${$t('share.shared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
);
addToast('success', `${$t('share.invite_sent')} ${user.first_name} ${user.last_name}`);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.invite_failed'));
}
}
// Unshare collection from user (remove from shared_with)
async function unshare(user: User) {
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
method: 'POST',
@ -45,15 +53,44 @@
}
});
if (res.ok) {
notSharedWithUsers = notSharedWithUsers.concat(user);
// Move user from shared to not shared
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
notSharedWithUsers = [...notSharedWithUsers, { ...user, status: 'available' }];
// Update collection shared_with array
if (collection.shared_with) {
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
}
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
`${$t('share.unshared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.unshare_failed'));
}
}
// Revoke pending invite
async function revokeInvite(user: User) {
let res = await fetch(`/api/collections/${collection.id}/revoke-invite/${user.uuid}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Update user status back to available
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
if (userIndex !== -1) {
notSharedWithUsers[userIndex].status = 'available';
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
}
addToast('success', `${$t('share.invite_revoked')} ${user.first_name} ${user.last_name}`);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.revoke_failed'));
}
}
@ -62,21 +99,38 @@
if (modal) {
modal.showModal();
}
let res = await fetch(`/auth/users`);
// Fetch users that can be shared with (includes status)
let res = await fetch(`/api/collections/${collection.id}/can-share/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
let data = await res.json();
allUsers = data;
sharedWithUsers = allUsers.filter((user) =>
(collection.shared_with ?? []).includes(user.uuid)
);
notSharedWithUsers = allUsers.filter(
(user) => !(collection.shared_with ?? []).includes(user.uuid)
);
console.log(sharedWithUsers);
console.log(notSharedWithUsers);
let users = await res.json();
allUsers = users.map((user: UserWithStatus) => ({
...user,
status: user.status || 'available'
}));
// Separate users based on sharing status
separateUsers();
}
});
function separateUsers() {
if (!collection.shared_with) {
collection.shared_with = [];
}
// Get currently shared users from allUsers that match shared_with UUIDs
sharedWithUsers = allUsers.filter((user) => collection.shared_with?.includes(user.uuid));
// Get not shared users (everyone else from allUsers)
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with?.includes(user.uuid));
}
function close() {
dispatch('close');
}
@ -86,6 +140,22 @@
dispatch('close');
}
}
// Handle user card actions
function handleUserAction(event: CustomEvent, action: string) {
const user = event.detail;
switch (action) {
case 'share':
sendInvite(user);
break;
case 'unshare':
unshare(user);
break;
case 'revoke':
revokeInvite(user);
break;
}
}
</script>
<dialog id="my_modal_1" class="modal">
@ -97,12 +167,22 @@
on:keydown={handleKeydown}
>
<!-- Title -->
<div class="space-y-1">
<h3 class="text-2xl font-bold">
{$t('adventures.share')}
{collection.name}
</h3>
<p class="text-base-content/70">{$t('share.share_desc')}</p>
<!-- Header -->
<div class="flex items-center justify-between border-b border-base-300 pb-4 mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<Share class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-2xl font-bold text-primary">
{$t('adventures.share')} <span class="text-base-content">{collection.name}</span>
</h3>
<p class="text-sm text-base-content/60">{$t('share.share_desc')}</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" on:click={close}>
<Clear class="w-5 h-5" />
</button>
</div>
<!-- Shared With Section -->
@ -110,15 +190,14 @@
<h4 class="text-lg font-semibold mb-2">{$t('share.shared_with')}</h4>
{#if sharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each sharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:unshare={(event) => handleUserAction(event, 'unshare')}
/>
{/each}
</div>
@ -129,25 +208,25 @@
<div class="divider"></div>
<!-- Not Shared With Section -->
<!-- Available Users Section -->
<div>
<h4 class="text-lg font-semibold mb-2">{$t('share.not_shared_with')}</h4>
<h4 class="text-lg font-semibold mb-2">{$t('share.available_users')}</h4>
{#if notSharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each notSharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:share={(event) => handleUserAction(event, 'share')}
on:revoke={(event) => handleUserAction(event, 'revoke')}
/>
{/each}
</div>
{:else}
<p class="text-neutral-content italic">{$t('share.no_users_shared')}</p>
<p class="text-neutral-content italic">{$t('share.no_available_users')}</p>
{/if}
</div>

View file

@ -10,8 +10,23 @@
export let sharing: boolean = false;
export let shared_with: string[] | undefined = undefined;
export let user: User & { status?: 'available' | 'pending' };
export let user: User;
$: isShared = shared_with?.includes(user.uuid) || false;
$: isPending = user.status === 'pending';
$: isAvailable = user.status === 'available';
function handleShare() {
dispatch('share', user);
}
function handleUnshare() {
dispatch('unshare', user);
}
function handleRevoke() {
dispatch('revoke', user);
}
</script>
<div
@ -44,6 +59,23 @@
{#if user.is_staff}
<div class="badge badge-outline badge-primary mt-2">{$t('settings.admin')}</div>
{/if}
<!-- Status Badge for sharing mode -->
{#if sharing}
{#if isPending}
<div class="badge badge-warning badge-sm mt-2">
{$t('share.pending')}
</div>
{:else if isShared}
<div class="badge badge-success badge-sm mt-2">
{$t('share.shared')}
</div>
{:else if isAvailable}
<div class="badge badge-ghost badge-sm mt-2">
{$t('share.available')}
</div>
{/if}
{/if}
</div>
<!-- Join Date -->
@ -65,14 +97,18 @@
>
{$t('adventures.view_profile')}
</button>
{:else if shared_with && !shared_with.includes(user.uuid)}
<button class="btn btn-sm btn-success w-full" on:click={() => dispatch('share', user)}>
{$t('adventures.share')}
</button>
{:else}
<button class="btn btn-sm btn-error w-full" on:click={() => dispatch('unshare', user)}>
{:else if isShared}
<button class="btn btn-sm btn-error w-full" on:click={handleUnshare}>
{$t('adventures.remove')}
</button>
{:else if isPending}
<button class="btn btn-sm btn-warning btn-outline w-full" on:click={handleRevoke}>
{$t('share.revoke_invite')}
</button>
{:else if isAvailable}
<button class="btn btn-sm btn-success w-full" on:click={handleShare}>
{$t('share.send_invite')}
</button>
{/if}
</div>
</div>

View file

@ -306,3 +306,10 @@ export type Lodging = {
updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the lodging
};
export type CollectionInvite = {
id: string;
collection: string; // UUID of the collection
name: string; // Name of the collection
created_at: string; // ISO 8601 date string
};

View file

@ -311,7 +311,12 @@
"reservation_number": "Reservation Number",
"filters_and_sort": "Filters & Sort",
"filters_and_stats": "Filters & Stats",
"travel_progress": "Travel Progress"
"travel_progress": "Travel Progress",
"left_collection_message": "Successfully left collection",
"leave_collection": "Leave Collection",
"leave": "Leave",
"leave_collection_warning": "Are you sure you want to leave this collection? Any locations you added will be unlinked and remain in your account.",
"loading_collections": "Loading collections..."
},
"worldtravel": {
"country_list": "Country List",
@ -761,5 +766,18 @@
"locations": "Locations",
"my_locations": "My Locations"
},
"settings_download_backup": "Download Backup"
"settings_download_backup": "Download Backup",
"invites": {
"accepted": "Invite accepted",
"accept_failed": "Failed to accept invite",
"declined": "Invite declined",
"decline_failed": "Failed to decline invite",
"title": "Invites",
"pending_invites": "Pending Invites",
"no_invites": "No invites",
"decline": "Decline",
"accept": "Accept",
"invited_on": "Invited on",
"no_invites_desc": "Make sure your profile is public so users can invite you."
}
}

View file

@ -67,6 +67,17 @@ export const load = (async (event) => {
}
let archivedCollections = (await archivedRes.json()) as Collection[];
let inviteRes = await fetch(`${serverEndpoint}/api/collections/invites/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!inviteRes.ok) {
console.error('Failed to fetch invites');
return redirect(302, '/login');
}
let invites = await inviteRes.json();
// Calculate current page from URL
const currentPage = parseInt(page);
@ -80,7 +91,8 @@ export const load = (async (event) => {
currentPage,
order_by,
order_direction,
archivedCollections
archivedCollections,
invites
}
};
}

View file

@ -5,7 +5,7 @@
import CollectionLink from '$lib/components/CollectionLink.svelte';
import CollectionModal from '$lib/components/CollectionModal.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Collection } from '$lib/types';
import type { Collection, CollectionInvite } from '$lib/types';
import { t } from 'svelte-i18n';
import Plus from '~icons/mdi/plus';
@ -14,6 +14,11 @@
import Archive from '~icons/mdi/archive';
import Share from '~icons/mdi/share-variant';
import CollectionIcon from '~icons/mdi/folder-multiple';
import MailIcon from '~icons/mdi/email';
import CheckIcon from '~icons/mdi/check';
import CloseIcon from '~icons/mdi/close';
import { addToast } from '$lib/toasts';
import DeleteWarning from '$lib/components/DeleteWarning.svelte';
export let data: any;
console.log('Collections page data:', data);
@ -25,7 +30,7 @@
let newType: string = '';
let resultsPerPage: number = 25;
let isShowingCollectionModal: boolean = false;
let activeView: 'owned' | 'shared' | 'archived' = 'owned';
let activeView: 'owned' | 'shared' | 'archived' | 'invites' = 'owned';
let next: string | null = data.props.next || null;
let previous: string | null = data.props.previous || null;
@ -35,6 +40,8 @@
let orderBy = data.props.order_by || 'updated_at';
let orderDirection = data.props.order_direction || 'asc';
let invites: CollectionInvite[] = data.props.invites || [];
let sidebarOpen = false;
let collectionToEdit: Collection | null = null;
@ -54,7 +61,9 @@
? sharedCollections.length
: activeView === 'archived'
? archivedCollections.length
: 0;
: activeView === 'invites'
? invites.length
: 0;
// Optionally, keep count in sync with collections only for owned view
$: {
@ -152,6 +161,26 @@
isShowingCollectionModal = true;
}
let isShowingConfirmLeaveModal: boolean = false;
let collectionIdToLeave: string | null = null;
async function leaveCollection() {
let res = await fetch(`/api/collections/${collectionIdToLeave}/leave`, {
method: 'POST'
});
if (res.ok) {
addToast('info', $t('adventures.left_collection_message'));
// Remove from shared collections
sharedCollections = sharedCollections.filter(
(collection) => collection.id !== collectionIdToLeave
);
// Optionally, you can also remove from owned collections if needed
collections = collections.filter((collection) => collection.id !== collectionIdToLeave);
} else {
console.log('Error leaving collection');
}
}
function saveEdit(event: CustomEvent<Collection>) {
collections = collections.map((adventure) => {
if (adventure.id === event.detail.id) {
@ -166,9 +195,60 @@
sidebarOpen = !sidebarOpen;
}
function switchView(view: 'owned' | 'shared' | 'archived') {
function switchView(view: 'owned' | 'shared' | 'archived' | 'invites') {
activeView = view;
}
// Invite functions
async function acceptInvite(invite: CollectionInvite) {
try {
const res = await fetch(`/api/collections/${invite.collection}/accept-invite/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Remove invite from list
invites = invites.filter((i) => i.id !== invite.id);
addToast('success', `${$t('invites.accepted')} "${invite.name}"`);
// Optionally refresh shared collections
await goto(window.location.pathname, { invalidateAll: true });
} else {
const error = await res.json();
addToast('error', error.error || $t('invites.accept_failed'));
}
} catch (error) {
addToast('error', $t('invites.accept_failed'));
}
}
async function declineInvite(invite: CollectionInvite) {
try {
const res = await fetch(`/api/collections/${invite.collection}/decline-invite/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Remove invite from list
invites = invites.filter((i) => i.id !== invite.id);
addToast('success', `${$t('invites.declined')} "${invite.name}"`);
} else {
const error = await res.json();
addToast('error', error.error || $t('invites.decline_failed'));
}
} catch (error) {
addToast('error', $t('invites.decline_failed'));
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString();
}
</script>
<svelte:head>
@ -176,6 +256,17 @@
<meta name="description" content="View your adventure collections." />
</svelte:head>
{#if isShowingConfirmLeaveModal}
<DeleteWarning
title={$t('adventures.leave_collection')}
button_text={$t('adventures.leave')}
description={$t('adventures.leave_collection_warning')}
is_warning={true}
on:close={() => (isShowingConfirmLeaveModal = false)}
on:confirm={leaveCollection}
/>
{/if}
{#if isShowingCollectionModal}
<CollectionModal
{collectionToEdit}
@ -204,7 +295,9 @@
</div>
<div>
<h1 class="text-3xl font-bold bg-clip-text text-primary">
{$t(`adventures.my_collections`)}
{activeView === 'invites'
? $t('invites.title')
: $t(`adventures.my_collections`)}
</h1>
<p class="text-sm text-base-content/60">
{currentCount}
@ -212,7 +305,9 @@
? $t('navbar.collections')
: activeView === 'shared'
? $t('collection.shared_collections')
: $t('adventures.archived_collections')}
: activeView === 'archived'
? $t('adventures.archived_collections')
: $t('invites.pending_invites')}
</p>
</div>
</div>
@ -258,6 +353,27 @@
{archivedCollections.length}
</div>
</button>
<button
class="tab gap-2 {activeView === 'invites' ? 'tab-active' : ''}"
on:click={() => switchView('invites')}
>
<div class="indicator">
<MailIcon class="w-4 h-4" />
{#if invites.length > 0}
<span class="indicator-item badge badge-xs badge-error"></span>
{/if}
</div>
<span class="hidden sm:inline">{$t('invites.title')}</span>
<div
class="badge badge-sm {activeView === 'invites'
? 'badge-primary'
: invites.length > 0
? 'badge-error'
: 'badge-ghost'}"
>
{invites.length}
</div>
</button>
</div>
</div>
</div>
@ -265,7 +381,68 @@
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
{#if currentCollections.length === 0}
{#if activeView === 'invites'}
<!-- Invites Content -->
{#if invites.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
<MailIcon class="w-16 h-16 text-base-content/30" />
</div>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{$t('invites.no_invites')}
</h3>
<p class="text-base-content/50 text-center max-w-md">
{$t('invites.no_invites_desc')}
</p>
</div>
{:else}
<div class="space-y-4">
{#each invites as invite}
<div
class="card bg-base-100 shadow-lg border border-base-300 hover:shadow-xl transition-shadow"
>
<div class="card-body p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<CollectionIcon class="w-5 h-5 text-primary" />
</div>
<div>
<h3 class="font-semibold text-lg">
{invite.name}
</h3>
<p class="text-xs text-base-content/50">
{$t('invites.invited_on')}
{formatDate(invite.created_at)}
</p>
</div>
</div>
</div>
<div class="flex gap-2 ml-4">
<button
class="btn btn-success btn-sm gap-2"
on:click={() => acceptInvite(invite)}
>
<CheckIcon class="w-4 h-4" />
{$t('invites.accept')}
</button>
<button
class="btn btn-error btn-sm btn-outline gap-2"
on:click={() => declineInvite(invite)}
>
<CloseIcon class="w-4 h-4" />
{$t('invites.decline')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else if currentCollections.length === 0}
<!-- Empty State for Collections -->
<div class="flex flex-col items-center justify-center py-16">
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
{#if activeView === 'owned'}
@ -318,6 +495,10 @@
on:archive={archiveCollection}
on:unarchive={unarchiveCollection}
user={data.user}
on:leave={(e) => {
collectionIdToLeave = e.detail;
isShowingConfirmLeaveModal = true;
}}
/>
{/each}
</div>
@ -356,79 +537,82 @@
<h2 class="text-xl font-bold">{$t('adventures.filters_and_sort')}</h2>
</div>
<!-- Sort Form - Updated to use URL navigation -->
<div class="card bg-base-200/50 p-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Sort class="w-5 h-5" />
{$t(`adventures.sort`)}
</h3>
<!-- Only show sort options for collection views, not invites -->
{#if activeView !== 'invites'}
<!-- Sort Form - Updated to use URL navigation -->
<div class="card bg-base-200/50 p-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Sort class="w-5 h-5" />
{$t(`adventures.sort`)}
</h3>
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
</label>
<div class="join w-full">
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'asc')}
>
{$t(`adventures.ascending`)}
</button>
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'desc')}
>
{$t(`adventures.descending`)}
</button>
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
</label>
<div class="join w-full">
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'asc')}
>
{$t(`adventures.ascending`)}
</button>
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'desc')}
>
{$t(`adventures.descending`)}
</button>
</div>
</div>
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
</label>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'updated_at'}
on:change={() => updateSort('updated_at', orderDirection)}
/>
<span class="label-text">{$t('adventures.updated')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'start_date'}
on:change={() => updateSort('start_date', orderDirection)}
/>
<span class="label-text">{$t('adventures.start_date')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'name'}
on:change={() => updateSort('name', orderDirection)}
/>
<span class="label-text">{$t('adventures.name')}</span>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
</label>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'updated_at'}
on:change={() => updateSort('updated_at', orderDirection)}
/>
<span class="label-text">{$t('adventures.updated')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'start_date'}
on:change={() => updateSort('start_date', orderDirection)}
/>
<span class="label-text">{$t('adventures.start_date')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'name'}
on:change={() => updateSort('name', orderDirection)}
/>
<span class="label-text">{$t('adventures.name')}</span>
</label>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { CollectionInvite } from '$lib/types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
if (!event.locals.user) {
redirect(302, '/login');
}
let res = await event.fetch(`${serverEndpoint}/api/collections/invites/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
});
if (!res.ok) {
return { status: res.status, error: new Error('Failed to fetch invites') };
}
const invites = (await res.json()) as CollectionInvite[];
return { invites };
}) satisfies PageServerLoad;

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
interface CollectionInvite {
id: string;
collection: string; // UUID of the collection
name: string; // Name of the collection
created_at: string; // ISO 8601 date string
}
let invites: CollectionInvite[] = [];
let loading = true;
async function fetchInvites() {
try {
const res = await fetch('/api/collections/invites/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
invites = await res.json();
} else {
addToast('error', $t('invites.fetch_failed'));
}
} catch (error) {
addToast('error', $t('invites.fetch_failed'));
} finally {
loading = false;
}
}
async function acceptInvite(invite: CollectionInvite) {
try {
const res = await fetch(`/api/collections/${invite.collection}/accept-invite/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Remove invite from list
invites = invites.filter((i) => i.id !== invite.id);
addToast('success', `${$t('invites.accepted')} "${invite.name}"`);
} else {
const error = await res.json();
addToast('error', error.error || $t('invites.accept_failed'));
}
} catch (error) {
addToast('error', $t('invites.accept_failed'));
}
}
async function declineInvite(invite: CollectionInvite) {
try {
const res = await fetch(`/api/collections/${invite.collection}/decline-invite/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Remove invite from list
invites = invites.filter((i) => i.id !== invite.id);
addToast('success', `${$t('invites.declined')} "${invite.name}"`);
} else {
const error = await res.json();
addToast('error', error.error || $t('invites.decline_failed'));
}
} catch (error) {
addToast('error', $t('invites.decline_failed'));
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString();
}
onMount(() => {
fetchInvites();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">{$t('invites.title')}</h2>
<button class="btn btn-sm btn-ghost" on:click={fetchInvites} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
{$t('common.refresh')}
{/if}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if invites.length === 0}
<div class="text-center py-8">
<div class="text-base-content/60 mb-2">
{$t('invites.no_invites')}
</div>
<p class="text-sm text-base-content/40">
{$t('invites.no_invites_desc')}
</p>
</div>
{:else}
<div class="space-y-3">
{#each invites as invite}
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-semibold text-lg mb-1">
{invite.name}
</h3>
<p class="text-xs text-base-content/50">
{$t('invites.invited_on')}
{formatDate(invite.created_at)}
</p>
</div>
<div class="flex gap-2 ml-4">
<button class="btn btn-success btn-sm" on:click={() => acceptInvite(invite)}>
{$t('invites.accept')}
</button>
<button
class="btn btn-error btn-sm btn-outline"
on:click={() => declineInvite(invite)}
>
{$t('invites.decline')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>