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:
parent
7f80dad94b
commit
ba162175fe
19 changed files with 641 additions and 245 deletions
|
@ -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>
|
||||
|
|
|
@ -68,7 +68,8 @@
|
|||
},
|
||||
body: JSON.stringify({
|
||||
immich_id: imageId,
|
||||
location: location.id
|
||||
object_id: location.id,
|
||||
content_type: 'location'
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -172,6 +172,7 @@
|
|||
locationToEdit={adventureToEdit}
|
||||
on:close={() => (isLocationModalOpen = false)}
|
||||
on:save={saveOrCreate}
|
||||
user={data.user}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -461,6 +461,7 @@
|
|||
on:close={() => (createModalOpen = false)}
|
||||
on:save={createNewAdventure}
|
||||
{initialLatLng}
|
||||
user={data.user}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue