1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-04 20:55:19 +02:00

Merge pull request #131 from seanmorley15/development

Development
This commit is contained in:
Sean Morley 2024-07-18 10:48:27 -04:00 committed by GitHub
commit 2434e7669d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 407 additions and 7 deletions

View file

@ -1,12 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription
from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription, ActivityTypesView
router = DefaultRouter()
router.register(r'adventures', AdventureViewSet, basename='adventures')
router.register(r'collections', CollectionViewSet, basename='collections')
router.register(r'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate')
router.register(r'activity-types', ActivityTypesView, basename='activity-types')
urlpatterns = [

View file

@ -139,6 +139,17 @@ class AdventureViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def search(self, request):
query = self.request.query_params.get('query', '')
queryset = Adventure.objects.filter(
(Q(name__icontains=query) | Q(description__icontains=query) | Q(location__icontains=query) | Q(activity_types__icontains=query)) &
(Q(user_id=request.user.id) | Q(is_public=True))
)
queryset = self.apply_sorting(queryset)
adventures = self.paginate_and_respond(queryset, request)
return adventures
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()
@ -330,4 +341,31 @@ class GenerateDescription(viewsets.ViewSet):
if extract.get('original') is None:
return Response({"error": "No image found"}, status=400)
return Response(extract["original"])
class ActivityTypesView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def types(self, request):
"""
Retrieve a list of distinct activity types for adventures associated with the current user.
Args:
request (HttpRequest): The HTTP request object.
Returns:
Response: A response containing a list of distinct activity types.
"""
types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct()
allTypes = []
for i in types:
if not i:
continue
for x in i:
if x and x not in allTypes:
allTypes.append(x)
return Response(allTypes)

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
export let activities: string[] | undefined | null;
let allActivities: string[] = [];
let inputVal: string = '';
if (activities == null || activities == undefined) {
activities = [];
}
onMount(async () => {
let res = await fetch('/api/activity-types/types/');
let data = await res.json();
if (data) {
allActivities = data;
}
});
function addActivity() {
if (inputVal && activities) {
const trimmedInput = inputVal.trim();
if (trimmedInput && !activities.includes(trimmedInput)) {
activities = [...activities, trimmedInput];
inputVal = '';
}
}
}
function removeActivity(item: string) {
if (activities) {
activities = activities.filter((activity) => activity !== item);
}
}
$: filteredItems = allActivities.filter(function (activity) {
return (
activity.toLowerCase().includes(inputVal.toLowerCase()) &&
(!activities || !activities.includes(activity))
);
});
</script>
<div class="relative">
<input
type="text"
class="input input-bordered w-full"
placeholder="Add an activity"
bind:value={inputVal}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addActivity();
}
}}
/>
{#if inputVal && filteredItems.length > 0}
<ul class="absolute z-10 w-full bg-base-100 shadow-lg max-h-60 overflow-auto">
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#each filteredItems as item}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<li
class="p-2 hover:bg-base-200 cursor-pointer"
on:click={() => {
inputVal = item;
addActivity();
}}
>
{item}
</li>
{/each}
</ul>
{/if}
</div>
<div class="mt-2">
<ul class="space-y-2">
{#if activities}
{#each activities as activity}
<li class="flex items-center justify-between bg-base-200 p-2 rounded">
{activity}
<button class="btn btn-sm btn-error" on:click={() => removeActivity(activity)}>
Remove
</button>
</li>
{/each}
{/if}
</ul>
</div>

View file

@ -31,6 +31,7 @@
<p class="text-lg ml-4 font-bold">Hi, {user.first_name} {user.last_name}</p>
<li><button on:click={() => goto('/profile')}>Profile</button></li>
<li><button on:click={() => goto('/adventures')}>My Adventures</button></li>
<li><button on:click={() => goto('/activities')}>My Activities</button></li>
<li><button on:click={() => goto('/settings')}>User Settings</button></li>
<form method="post">
<li><button formaction="/?/logout">Logout</button></li>

View file

@ -27,6 +27,7 @@
import Earth from '~icons/mdi/earth';
import Wikipedia from '~icons/mdi/wikipedia';
import ImageFetcher from './ImageFetcher.svelte';
import ActivityComplete from './ActivityComplete.svelte';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
@ -213,9 +214,11 @@
type="text"
id="activity_types"
name="activity_types"
hidden
bind:value={adventureToEdit.activity_types}
class="input input-bordered w-full max-w-xs mt-1"
/>
<ActivityComplete bind:activities={adventureToEdit.activity_types} />
</div>
<div class="mb-2">
<label for="image">Image </label><br />

View file

@ -12,6 +12,9 @@
import Water from '~icons/mdi/water';
import AboutModal from './AboutModal.svelte';
import Avatar from './Avatar.svelte';
import { page } from '$app/stores';
let query: string = '';
let isAboutModalOpen: boolean = false;
@ -22,6 +25,22 @@
document.documentElement.setAttribute('data-theme', theme);
}
};
const searchGo = async (e: Event) => {
e.preventDefault();
let reload: boolean = false;
if ($page.url.pathname === '/search') {
reload = true;
}
if (query) {
await goto(`/search?query=${query}`);
if (reload) {
window.location.reload();
}
}
};
</script>
{#if isAboutModalOpen}
@ -96,6 +115,24 @@
<li>
<button class="btn btn-neutral" on:click={() => goto('/map')}>Map</button>
</li>
<label class="input input-bordered flex items-center gap-2">
<form>
<input type="text" bind:value={query} class="grow" placeholder="Search" />
</form>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/>
</svg>
</label>
<button on:click={searchGo} type="submit" class="btn btn-neutral">Search</button>
{/if}
{#if !data.user}

View file

@ -10,6 +10,8 @@
export let type: string = 'visited';
import Wikipedia from '~icons/mdi/wikipedia';
import ClipboardList from '~icons/mdi/clipboard-list';
import ActivityComplete from './ActivityComplete.svelte';
let newAdventure: Adventure = {
id: NaN,
@ -218,17 +220,18 @@
</div>
</div>
<div class="mb-2">
<label for="activity_types"
>Activity Types <iconify-icon icon="mdi:clipboard-list" class="text-xl -mb-1"
></iconify-icon></label
<label for="activityTypes"
>Activity Types <ClipboardList class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<input
type="text"
name="activity_types"
id="activity_types"
name="activity_types"
hidden
bind:value={newAdventure.activity_types}
class="input input-bordered w-full max-w-xs mt-1"
/>
<ActivityComplete bind:activities={newAdventure.activity_types} />
</div>
<div class="mb-2">
<label for="rating"

View file

@ -1,4 +1,5 @@
import inspirationalQuotes from './json/quotes.json';
import type { Adventure, Collection } from './types';
export function getRandomQuote() {
const quotes = inspirationalQuotes.quotes;
@ -19,3 +20,48 @@ export function checkLink(link: string) {
return 'http://' + link + '.com';
}
}
export async function exportData() {
let res = await fetch('/api/adventures/all');
let adventures = (await res.json()) as Adventure[];
res = await fetch('/api/collections/all');
let collections = (await res.json()) as Collection[];
res = await fetch('/api/visitedregion');
let visitedRegions = await res.json();
const data = {
adventures,
collections,
visitedRegions
};
async function convertImages() {
const promises = data.adventures.map(async (adventure, i) => {
if (adventure.image) {
const res = await fetch(adventure.image);
const blob = await res.blob();
const base64 = await blobToBase64(blob);
adventure.image = base64;
data.adventures[i].image = adventure.image;
}
});
await Promise.all(promises);
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
}
await convertImages();
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
return URL.createObjectURL(blob);
}

View file

@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
let allActivities: string[] = [];
let res = await fetch(`${endpoint}/api/activity-types/types/`, {
headers: {
'Content-Type': 'application/json',
Cookie: `${event.cookies.get('auth')}`
}
});
let data = await res.json();
if (data) {
allActivities = data;
}
return {
props: {
activities: allActivities
}
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let activities: string[] = data.props.activities;
</script>
<!-- make a table with pinned rows -->
<table class="table table-compact">
<thead>
<tr>
<th>Activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each activities as activity}
<tr>
<td>{activity}</td>
<td>
<!-- <button
class="btn btn-sm btn-error"
on:click={() => {
activities = activities.filter((a) => a !== activity);
}}>Remove</button
> -->
</td>
</tr>
{/each}
</tbody>
</table>
<!-- </ul> -->

View file

@ -193,6 +193,44 @@
{/each}
</div>
{/if}
{#if currentView == 'table'}
<table class="table table-compact w-full">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
<th>Rating</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each adventures as adventure}
<tr>
<td>{adventure.name}</td>
<td>{adventure.date}</td>
<td>{adventure.rating}</td>
<td>{adventure.type}</td>
<td>
<button
class="btn btn-sm btn-primary"
on:click={() => editAdventure(new CustomEvent('edit', { detail: adventure }))}
>
Edit
</button>
<button
class="btn btn-sm btn-error"
on:click={() =>
deleteAdventure(new CustomEvent('delete', { detail: adventure.id }))}
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="join flex items-center justify-center mt-4">
{#if next || previous}
<div class="join">

View file

@ -0,0 +1,39 @@
import type { Adventure } from '$lib/types';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
// get url param query
const query = event.url.searchParams.get('query');
if (!query) {
return { data: [] };
}
let res = await fetch(`${serverEndpoint}/api/adventures/search/?query=${query}`, {
headers: {
'Content-Type': 'application/json',
Cookie: `${event.cookies.get('auth')}`
}
});
if (res.ok) {
let data = await res.json();
console.log('Search data:', data);
return {
props: {
adventures: data.results as Adventure[],
nextPage: data.next,
prevPage: data.previous,
total: data.count,
query
}
};
} else {
console.error('Failed to fetch search data');
return { data: [] };
}
}) satisfies PageServerLoad;

View file

@ -0,0 +1,28 @@
<script lang="ts">
import AdventureCard from '$lib/components/AdventureCard.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Adventure } from '$lib/types';
import type { PageData } from './$types';
export let data: PageData;
function deleteAdventure(event: CustomEvent<number>) {
adventures = adventures.filter((adventure) => adventure.id !== event.detail);
}
console.log(data);
let adventures: Adventure[] = [];
if (data.props) {
adventures = data.props.adventures;
}
</script>
{#if adventures.length === 0}
<NotFound />
{:else}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each adventures as adventure}
<AdventureCard type={adventure.type} {adventure} on:delete={deleteAdventure} />
{/each}
</div>
{/if}

View file

@ -3,9 +3,10 @@
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { addToast } from '$lib/toasts';
import type { User } from '$lib/types.js';
import type { Adventure, Collection, User } from '$lib/types.js';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { exportData } from '$lib';
export let data;
let user: User;
@ -33,6 +34,16 @@
addToast('error', 'Error updating settings');
}
}
async function exportAdventures() {
const url = await exportData();
const a = document.createElement('a');
a.href = url;
a.download = 'adventure-log-export.json';
a.click();
URL.revokeObjectURL(url);
}
</script>
<h1 class="text-center font-extrabold text-4xl mb-6">Settings Page</h1>
@ -141,6 +152,11 @@
<button class="py-2 px-4 btn btn-primary mt-2">Change Email</button>
</form>
</div>
<div class="flex flex-col items-center">
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">Data Export</h1>
<button class="btn btn-neutral mb-4" on:click={exportAdventures}> Export to JSON </button>
<p>This may take a few seconds...</p>
</div>
<small class="text-center"
><b>For Debug Use:</b> Server PK={user.pk} | Date Joined: {user.date_joined