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

feat: Enhance collection sharing and location management features

- Implemented unsharing functionality in CollectionViewSet, including removal of user-owned locations from collections.
- Refactored ContentImageViewSet to support multiple content types and improved permission checks for image uploads.
- Added user ownership checks in LocationViewSet for delete operations.
- Enhanced collection management in the frontend to display both owned and shared collections separately.
- Updated Immich integration to handle access control based on location visibility and user permissions.
- Improved UI components to show creator information and manage collection links more effectively.
- Added loading states and error handling in collection fetching logic.
This commit is contained in:
Sean Morley 2025-07-12 09:20:23 -04:00
parent 7f80dad94b
commit ba162175fe
19 changed files with 641 additions and 245 deletions

View file

@ -12,19 +12,23 @@
import Search from '~icons/mdi/magnify';
import Clear from '~icons/mdi/close';
import Link from '~icons/mdi/link-variant';
import Share from '~icons/mdi/share-variant';
let collections: Collection[] = [];
let sharedCollections: Collection[] = [];
let allCollections: Collection[] = [];
let filteredCollections: Collection[] = [];
let searchQuery: string = '';
let loading = true;
export let linkedCollectionList: string[] | null = null;
// Search functionality following worldtravel pattern
$: {
if (searchQuery === '') {
filteredCollections = collections;
filteredCollections = allCollections;
} else {
filteredCollections = collections.filter((collection) =>
filteredCollections = allCollections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
@ -36,28 +40,57 @@
modal.showModal();
}
let res = await fetch(`/api/collections/all/`, {
method: 'GET'
});
try {
// Fetch both own collections and shared collections
const [ownRes, sharedRes] = await Promise.all([
fetch(`/api/collections/all/`, { method: 'GET' }),
fetch(`/api/collections/shared`, { method: 'GET' })
]);
let result = await res.json();
const ownResult = await ownRes.json();
const sharedResult = await sharedRes.json();
if (result.type === 'success' && result.data) {
collections = result.data.adventures as Collection[];
} else {
collections = result as Collection[];
// Process own collections
if (ownResult.type === 'success' && ownResult.data) {
collections = ownResult.data.adventures as Collection[];
} else {
collections = ownResult as Collection[];
}
// Process shared collections
if (sharedResult.type === 'success' && sharedResult.data) {
sharedCollections = sharedResult.data.adventures as Collection[];
} else {
sharedCollections = sharedResult as Collection[];
}
// Don't combine collections - keep them separate
allCollections = collections;
// Move linked collections to the front for each collection type
if (linkedCollectionList) {
collections.sort((a, b) => {
const aLinked = linkedCollectionList?.includes(a.id);
const bLinked = linkedCollectionList?.includes(b.id);
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
});
sharedCollections.sort((a, b) => {
const aLinked = linkedCollectionList?.includes(a.id);
const bLinked = linkedCollectionList?.includes(b.id);
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
});
}
} catch (error) {
console.error('Error fetching collections:', error);
// Fallback to empty arrays
collections = [];
sharedCollections = [];
allCollections = [];
filteredCollections = [];
} finally {
loading = false;
}
// Move linked collections to the front
if (linkedCollectionList) {
collections.sort((a, b) => {
const aLinked = linkedCollectionList?.includes(a.id);
const bLinked = linkedCollectionList?.includes(b.id);
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
});
}
filteredCollections = collections;
});
function close() {
@ -80,7 +113,23 @@
// Statistics following worldtravel pattern
$: linkedCount = linkedCollectionList ? linkedCollectionList.length : 0;
$: totalCollections = collections.length;
$: totalCollections = collections.length + sharedCollections.length;
$: ownCollectionsCount = collections.length;
$: sharedCollectionsCount = sharedCollections.length;
// Filtered collections for display
$: filteredOwnCollections =
searchQuery === ''
? collections
: collections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
$: filteredSharedCollections =
searchQuery === ''
? sharedCollections
: sharedCollections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
</script>
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
@ -106,7 +155,7 @@
{$t('adventures.my_collections')}
</h1>
<p class="text-sm text-base-content/60">
{filteredCollections.length}
{filteredOwnCollections.length + filteredSharedCollections.length}
{$t('worldtravel.of')}
{totalCollections}
{$t('navbar.collections')}
@ -122,8 +171,15 @@
<div class="stat-value text-lg text-success">{linkedCount}</div>
</div>
<div class="stat py-2 px-4">
<div class="stat-title text-xs">{$t('collection.available')}</div>
<div class="stat-value text-lg text-info">{totalCollections}</div>
<div class="stat-title text-xs">{$t('navbar.collections')}</div>
<div class="stat-value text-lg text-info">{ownCollectionsCount}</div>
</div>
<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')}
</div>
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
</div>
</div>
</div>
@ -164,8 +220,13 @@
</div>
<!-- Main Content -->
<div class="px-2">
{#if filteredCollections.length === 0}
<div class="px-6">
{#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>
</div>
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.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">
<Collections class="w-16 h-16 text-base-content/30" />
@ -192,17 +253,69 @@
</div>
{:else}
<!-- Collections Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 p-4">
{#each filteredCollections as collection}
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
user={null}
/>
{/each}
<div class="space-y-8">
<!-- Own Collections Section -->
{#if filteredOwnCollections.length > 0}
<div>
<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')}
</h2>
<div class="badge badge-primary badge-sm">
{filteredOwnCollections.length}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each filteredOwnCollections as collection}
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
user={null}
/>
{/each}
</div>
</div>
{/if}
<!-- Shared Collections Section -->
{#if filteredSharedCollections.length > 0}
<div>
<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')}
</h2>
<div class="badge badge-warning badge-sm">
{filteredSharedCollections.length}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each filteredSharedCollections as collection}
<div class="relative">
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
user={null}
/>
<!-- Shared badge overlay -->
<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')}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -68,7 +68,8 @@
},
body: JSON.stringify({
immich_id: imageId,
location: location.id
object_id: location.id,
content_type: 'location'
})
});
if (res.ok) {

View file

@ -62,6 +62,16 @@
}
}
// Creator avatar helpers
$: creatorInitials =
adventure.user?.first_name && adventure.user?.last_name
? `${adventure.user.first_name[0]}${adventure.user.last_name[0]}`
: adventure.user?.first_name?.[0] || adventure.user?.username?.[0] || '?';
$: creatorDisplayName = adventure.user?.first_name
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
: adventure.user?.username || 'Unknown User';
// Helper functions for display
function formatVisitCount() {
const count = adventure.visits.length;
@ -221,6 +231,31 @@
</div>
</div>
{/if}
<!-- Creator Avatar -->
{#if adventure.user}
<div class="absolute bottom-4 right-4">
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
<div class="avatar">
<div class="w-8 h-8 rounded-full ring-2 ring-white/50 shadow-lg">
{#if adventure.user.profile_pic}
<img
src={adventure.user.profile_pic}
alt={creatorDisplayName}
class="rounded-full object-cover"
/>
{:else}
<div
class="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs shadow-lg"
>
{creatorInitials.toUpperCase()}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Content Section -->
@ -280,7 +315,7 @@
{$t('adventures.open_details')}
</button>
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
@ -307,20 +342,34 @@
{$t('collection.manage_collections')}
</button>
</li>
{:else if collection && user && collection.user == user.uuid}
<li>
<button
on:click={() =>
removeFromCollection(
new CustomEvent('unlink', { detail: collection.id })
)}
class="flex items-center gap-2"
>
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if user.uuid == adventure.user?.uuid}
<div class="divider my-1"></div>
<li>
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
</ul>
</div>
{/if}

View file

@ -1,10 +1,11 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import type { Location, Attachment, Category, Collection } from '$lib/types';
import type { Location, Attachment, Category, Collection, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n';
export let collection: Collection | null = null;
export let user: User | null = null;
let fullStartDate: string = '';
let fullEndDate: string = '';
@ -92,6 +93,7 @@
import AttachmentCard from './AttachmentCard.svelte';
import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
import UserCard from './UserCard.svelte';
let modal: HTMLDialogElement;
let wikiError: string = '';
@ -325,7 +327,8 @@
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('location', location.id);
formData.append('object_id', location.id);
formData.append('content_type', 'location');
let res = await fetch(`/locations?/image`, {
method: 'POST',
@ -537,8 +540,21 @@
<label for="link"
>{$t('adventures.category')}<span class="text-red-500">*</span></label
><br />
<CategoryDropdown bind:categories bind:selected_category={location.category} />
{#if (user && user.uuid == location.user?.uuid) || !locationToEdit}
<CategoryDropdown bind:categories bind:selected_category={location.category} />
{:else}
<!-- read only view of category info name and icon -->
<div
class="flex items-center space-x-3 p-3 bg-base-100 border border-base-300 rounded-lg"
>
{#if location.category?.icon}
<span class="text-2xl flex-shrink-0">{location.category.icon}</span>
{/if}
<span class="text-base font-medium text-base-content">
{location.category?.display_name || location.category?.name}
</span>
</div>
{/if}
</div>
<div>
<label for="rating">{$t('adventures.rating')}</label><br />

View file

@ -694,6 +694,7 @@
on:close={() => (isLocationModalOpen = false)}
on:save={saveOrCreateAdventure}
{collection}
user={data.user}
/>
{/if}
@ -760,7 +761,7 @@
tabindex="0"
class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4"
>
{#if collection.user === data.user.uuid}
{#if collection.user === data.user.uuid || (collection.shared_with && collection.shared_with.includes(data.user.uuid))}
<p class="text-center font-bold text-lg">{$t('adventures.link_new')}</p>
<button
class="btn btn-primary"

View file

@ -172,6 +172,7 @@
locationToEdit={adventureToEdit}
on:close={() => (isLocationModalOpen = false)}
on:save={saveOrCreate}
user={data.user}
/>
{/if}

View file

@ -122,7 +122,7 @@
}
function openImageModal(imageIndex: number) {
adventure_images = adventure.images.map(img => ({
adventure_images = adventure.images.map((img) => ({
image: img.image,
adventure: adventure
}));
@ -151,6 +151,7 @@
locationToEdit={adventure}
on:close={() => (isEditModalOpen = false)}
on:save={saveEdit}
user={data.user}
/>
{/if}

View file

@ -461,6 +461,7 @@
on:close={() => (createModalOpen = false)}
on:save={createNewAdventure}
{initialLatLng}
user={data.user}
/>
{/if}