Compare commits
8 Commits
notificati
...
biguiupdat
| Author | SHA1 | Date | |
|---|---|---|---|
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
@@ -88,6 +88,7 @@
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
@@ -37,7 +36,6 @@
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let showModal = $state(true);
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
@@ -54,13 +52,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showModal && $coordinates) {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
@@ -129,8 +121,8 @@
|
||||
}
|
||||
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating find:', error);
|
||||
alert('Failed to create find. Please try again.');
|
||||
@@ -173,16 +165,27 @@
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create Find</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
@@ -191,7 +194,7 @@
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
@@ -334,7 +337,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -343,24 +346,106 @@
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.create-find-sheet) {
|
||||
padding: 0 !important;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global(.create-find-sheet) {
|
||||
height: 90vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -369,13 +454,14 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -390,14 +476,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 80px;
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
@@ -407,6 +487,7 @@
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
@@ -448,13 +529,14 @@
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
@@ -485,7 +567,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
@@ -493,6 +575,7 @@
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
@@ -500,7 +583,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
@@ -516,24 +599,26 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.actions {
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
@@ -544,18 +629,19 @@
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
@@ -567,7 +653,7 @@
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
@@ -578,38 +664,53 @@
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-content {
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
flex: none;
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -161,16 +161,8 @@
|
||||
|
||||
<style>
|
||||
.find-card {
|
||||
background: white;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
margin-bottom: 1rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.find-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
@@ -294,12 +286,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button) {
|
||||
gap: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button:hover) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||
@@ -36,7 +35,6 @@
|
||||
|
||||
let { find, onClose, currentUserId }: Props = $props();
|
||||
|
||||
let showModal = $state(true);
|
||||
let currentMediaIndex = $state(0);
|
||||
let isMobile = $state(false);
|
||||
|
||||
@@ -54,13 +52,6 @@
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
});
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
function nextMedia() {
|
||||
if (!find?.media) return;
|
||||
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||
@@ -110,9 +101,9 @@
|
||||
</script>
|
||||
|
||||
{#if find}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||
<SheetHeader class="sheet-header">
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="user-section">
|
||||
<ProfilePicture
|
||||
username={find.user.username}
|
||||
@@ -120,7 +111,7 @@
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||
<h2 class="find-title">{find.title}</h2>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{find.user.username}</span>
|
||||
<span class="separator">•</span>
|
||||
@@ -132,9 +123,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="modal-body">
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
@@ -263,37 +265,78 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Base styles for sheet content */
|
||||
:global(.sheet-content) {
|
||||
padding: 0 !important;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Desktop styles (side sheet) */
|
||||
@media (min-width: 768px) {
|
||||
:global(.sheet-content) {
|
||||
width: 80vw !important;
|
||||
max-width: 600px !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles (bottom sheet) */
|
||||
@media (max-width: 767px) {
|
||||
:global(.sheet-content) {
|
||||
height: 50vh !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.sheet-header) {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -301,7 +344,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
@@ -322,13 +366,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-family: 'Washington', serif !important;
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
line-height: 1.3 !important;
|
||||
.find-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
@@ -359,7 +403,27 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -387,8 +451,8 @@
|
||||
|
||||
/* Desktop media viewer - maximize available space */
|
||||
@media (min-width: 768px) {
|
||||
.sheet-body {
|
||||
height: calc(100vh - 140px);
|
||||
.modal-body {
|
||||
height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
@@ -404,8 +468,8 @@
|
||||
|
||||
/* Mobile media viewer - maximize available space */
|
||||
@media (max-width: 767px) {
|
||||
.sheet-body {
|
||||
height: calc(80vh - 140px);
|
||||
.modal-body {
|
||||
height: calc(90vh - 180px);
|
||||
}
|
||||
|
||||
.media-container {
|
||||
@@ -490,6 +554,7 @@
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -556,15 +621,16 @@
|
||||
.comments-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Desktop comments section */
|
||||
@media (min-width: 768px) {
|
||||
.comments-section {
|
||||
height: calc(100vh - 400px);
|
||||
height: calc(100vh - 500px);
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -572,14 +638,14 @@
|
||||
/* Mobile comments section */
|
||||
@media (max-width: 767px) {
|
||||
.comments-section {
|
||||
height: calc(80vh - 350px);
|
||||
height: calc(90vh - 450px);
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 640px) {
|
||||
:global(.sheet-header) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -592,8 +658,8 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-size: 1.125rem !important;
|
||||
.find-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
@@ -610,7 +676,7 @@
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
height: calc(80vh - 380px);
|
||||
height: calc(90vh - 480px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
.app-header {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -38,7 +37,6 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
locationActions,
|
||||
locationStatus,
|
||||
locationError,
|
||||
isLocationLoading,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
variant?: 'primary' | 'secondary' | 'icon';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
showLabel = true
|
||||
}: Props = $props();
|
||||
|
||||
// Track if location watching is active from the derived store
|
||||
|
||||
async function handleLocationClick() {
|
||||
if ($isWatching) {
|
||||
// Stop watching if currently active
|
||||
locationActions.stopWatching();
|
||||
toast.success('Location watching stopped');
|
||||
} else {
|
||||
// Try to get current location first, then start watching
|
||||
const result = await locationActions.getCurrentLocation({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Start watching for continuous updates
|
||||
locationActions.startWatching({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 60000 // Update every minute
|
||||
});
|
||||
toast.success('Location watching started');
|
||||
} else if ($locationError) {
|
||||
toast.error($locationError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = $derived(() => {
|
||||
if ($isLocationLoading) return 'Finding location...';
|
||||
if ($isWatching) return 'Stop watching location';
|
||||
if ($locationStatus === 'success') return 'Watch location';
|
||||
return 'Find my location';
|
||||
});
|
||||
|
||||
const iconClass = $derived(() => {
|
||||
if ($isLocationLoading) return 'loading';
|
||||
if ($isWatching) return 'watching';
|
||||
if ($locationStatus === 'success') return 'success';
|
||||
if ($locationStatus === 'error') return 'error';
|
||||
return 'default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="location-button {variant} {size} {className}"
|
||||
onclick={handleLocationClick}
|
||||
disabled={$isLocationLoading}
|
||||
title={buttonText()}
|
||||
>
|
||||
<span class="icon {iconClass()}">
|
||||
{#if $isLocationLoading}
|
||||
<div class="loading-skeleton">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
{:else if $isWatching}
|
||||
<svg viewBox="0 0 24 24" class="watching-icon">
|
||||
<path
|
||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="9"
|
||||
r="15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
opacity="0.3"
|
||||
class="pulse-ring"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $locationStatus === 'success'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $locationStatus === 'error'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showLabel && variant !== 'icon'}
|
||||
<span class="label">{buttonText()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.location-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.icon:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small.icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.medium.icon {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 12px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.large.icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.small .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.large .icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon.success svg {
|
||||
color: #10b981;
|
||||
fill: #10b981;
|
||||
}
|
||||
|
||||
.icon.watching svg {
|
||||
color: #f59e0b;
|
||||
fill: #f59e0b;
|
||||
}
|
||||
|
||||
.icon.error svg {
|
||||
color: #ef4444;
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.watching-icon .pulse-ring {
|
||||
animation: pulse-ring 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.location-button .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location-button {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,6 @@
|
||||
locationActions,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import LocationButton from './LocationButton.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Find {
|
||||
@@ -39,7 +38,6 @@
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
showLocationButton?: boolean;
|
||||
autoCenter?: boolean;
|
||||
finds?: Find[];
|
||||
onFindClick?: (find: Find) => void;
|
||||
@@ -67,7 +65,6 @@
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
showLocationButton = true,
|
||||
autoCenter = true,
|
||||
finds = [],
|
||||
onFindClick
|
||||
@@ -179,12 +176,6 @@
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
{#if showLocationButton}
|
||||
<div class="location-controls">
|
||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,17 +210,9 @@
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal.dropdown {
|
||||
@@ -171,11 +172,12 @@
|
||||
right: 0;
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
export const sheetVariants = tv({
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
variants: {
|
||||
side: {
|
||||
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-[9998] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -8,7 +8,6 @@ export { default as ProfilePictureSheet } from './components/ProfilePictureSheet
|
||||
export { default as Header } from './components/Header.svelte';
|
||||
export { default as Modal } from './components/Modal.svelte';
|
||||
export { default as Map } from './components/Map.svelte';
|
||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||
export { default as LocationManager } from './components/LocationManager.svelte';
|
||||
export { default as NotificationManager } from './components/NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './components/NotificationPrompt.svelte';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
// Generate signed URL for profile picture if it exists
|
||||
// Generate local proxy URL for profile picture if it exists
|
||||
let profilePictureUrl = user.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
// It's a path, generate signed URL
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
// It's a path, generate local proxy URL
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
export function getLocalR2Url(path: string): string {
|
||||
return `/api/media/${path}`;
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@
|
||||
|
||||
<style>
|
||||
.header-skeleton {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
let showCreateModal = $state(false);
|
||||
let selectedFind: FindPreviewData | null = $state(null);
|
||||
let currentFilter = $state('all');
|
||||
let isSidebarVisible = $state(true);
|
||||
|
||||
// Initialize API sync with server data on mount
|
||||
onMount(async () => {
|
||||
@@ -215,6 +216,10 @@
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
isSidebarVisible = !isSidebarVisible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -238,44 +243,43 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<div class="map-section">
|
||||
<Map
|
||||
showLocationButton={true}
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="finds-section">
|
||||
<div class="finds-sticky-header">
|
||||
<div class="finds-header-content">
|
||||
<div class="finds-title-section">
|
||||
<h2 class="finds-title">Finds</h2>
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
<Button onclick={openCreateModal} class="create-find-button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating action button for mobile -->
|
||||
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
<!-- Toggle button -->
|
||||
<button class="sidebar-toggle" onclick={toggleSidebar} aria-label="Toggle finds list">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isSidebarVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Left sidebar with finds list -->
|
||||
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||
<div class="finds-header">
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
<Button onclick={openCreateModal} class="create-find-button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
</div>
|
||||
<div class="finds-list-container">
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
@@ -293,145 +297,148 @@
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 500px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
position: sticky;
|
||||
.map-section {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: white;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 90px;
|
||||
left: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 80px;
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
.finds-list-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
.sidebar-toggle {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
height: 50vh;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 16px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 300px;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
.finds-sidebar {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 12px 12px 8px 12px;
|
||||
.finds-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateFindId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -176,31 +176,24 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Generate signed URLs for all media items
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
findMedia.map(async (mediaItem) => {
|
||||
// URLs in database are now paths, generate signed URLs directly
|
||||
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
||||
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
||||
]);
|
||||
// URLs in database are now paths, generate local proxy URLs
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: signedUrl,
|
||||
thumbnailUrl: signedThumbnailUrl
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate signed URL for user profile picture if it exists
|
||||
// Generate local proxy URL for user profile picture if it exists
|
||||
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
||||
userProfilePictureUrl = null;
|
||||
}
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
|
||||
import { friendship, user } from '$lib/server/db/schema';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
@@ -98,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||
}
|
||||
|
||||
// Generate signed URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = await Promise.all(
|
||||
(friendships || []).map(async (friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
});
|
||||
|
||||
return json(friendshipsWithSignedUrls);
|
||||
} catch (err) {
|
||||
|
||||
63
src/routes/api/media/[...path]/+server.ts
Normal file
63
src/routes/api/media/[...path]/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client } from '$lib/server/r2';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const path = params.path;
|
||||
|
||||
if (!path) {
|
||||
throw error(400, 'Path is required');
|
||||
}
|
||||
|
||||
if (!R2_BUCKET_NAME) {
|
||||
throw error(500, 'R2_BUCKET_NAME environment variable is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
});
|
||||
|
||||
const response = await r2Client.send(command);
|
||||
|
||||
if (!response.Body) {
|
||||
throw error(404, 'File not found');
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const body = response.Body as AsyncIterable<Uint8Array>;
|
||||
for await (const chunk of body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': response.ContentType || 'application/octet-stream',
|
||||
'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(buffer);
|
||||
} catch (err) {
|
||||
console.error('Error fetching file from R2:', err);
|
||||
throw error(500, 'Failed to fetch file');
|
||||
}
|
||||
};
|
||||
|
||||
export const OPTIONS: RequestHandler = async ({ setHeaders }) => {
|
||||
setHeaders({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
@@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
friendshipStatusMap.set(otherUserId, f.status);
|
||||
});
|
||||
|
||||
// Generate signed URLs and add friendship status
|
||||
const usersWithSignedUrls = await Promise.all(
|
||||
users.map(async (userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs and add friendship status
|
||||
const usersWithSignedUrls = users.map((userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
});
|
||||
|
||||
return json(usersWithSignedUrls);
|
||||
} catch (err) {
|
||||
|
||||
@@ -130,8 +130,8 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle R2 resources with cache-first strategy
|
||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
||||
// Handle R2 resources and local media proxy with cache-first strategy
|
||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) {
|
||||
const cachedResponse = await r2Cache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
@@ -146,7 +146,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return response;
|
||||
} catch {
|
||||
// Return cached version if available, or fall through to other cache checks
|
||||
return cachedResponse || new Response('R2 resource not available', { status: 404 });
|
||||
return cachedResponse || new Response('Media resource not available', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user