Fix share assets, shared photo UI, and live show expiry
This commit is contained in:
@@ -1464,8 +1464,7 @@ class EventPublicController extends BaseController
|
|||||||
[
|
[
|
||||||
'slug' => $shareLink->slug,
|
'slug' => $shareLink->slug,
|
||||||
'variant' => $variant,
|
'variant' => $variant,
|
||||||
],
|
]
|
||||||
absolute: false
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ class LiveShowController extends BaseController
|
|||||||
|
|
||||||
return Event::query()
|
return Event::query()
|
||||||
->where('live_show_token', $token)
|
->where('live_show_token', $token)
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
$query->whereNull('live_show_token_expires_at')
|
||||||
|
->orWhere('live_show_token_expires_at', '>=', now());
|
||||||
|
})
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class LiveShowLinkController extends Controller
|
|||||||
'url' => $url,
|
'url' => $url,
|
||||||
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
||||||
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
||||||
|
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Event extends Model
|
|||||||
'name' => 'array',
|
'name' => 'array',
|
||||||
'description' => 'array',
|
'description' => 'array',
|
||||||
'live_show_token_rotated_at' => 'datetime',
|
'live_show_token_rotated_at' => 'datetime',
|
||||||
|
'live_show_token_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
@@ -47,6 +48,7 @@ class Event extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
app(EventJoinTokenService::class)->extendExpiryForEvent($event);
|
app(EventJoinTokenService::class)->extendExpiryForEvent($event);
|
||||||
|
$event->refreshLiveShowTokenExpiry();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +166,8 @@ class Event extends Model
|
|||||||
public function ensureLiveShowToken(): string
|
public function ensureLiveShowToken(): string
|
||||||
{
|
{
|
||||||
if (is_string($this->live_show_token) && $this->live_show_token !== '') {
|
if (is_string($this->live_show_token) && $this->live_show_token !== '') {
|
||||||
|
$this->refreshLiveShowTokenExpiry();
|
||||||
|
|
||||||
return $this->live_show_token;
|
return $this->live_show_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +183,34 @@ class Event extends Model
|
|||||||
$this->forceFill([
|
$this->forceFill([
|
||||||
'live_show_token' => $token,
|
'live_show_token' => $token,
|
||||||
'live_show_token_rotated_at' => now(),
|
'live_show_token_rotated_at' => now(),
|
||||||
|
'live_show_token_expires_at' => $this->computeLiveShowTokenExpiry(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function refreshLiveShowTokenExpiry(): void
|
||||||
|
{
|
||||||
|
if (! is_string($this->live_show_token) || $this->live_show_token === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'live_show_token_expires_at' => $this->computeLiveShowTokenExpiry(),
|
||||||
|
])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeLiveShowTokenExpiry(): \Carbon\CarbonInterface
|
||||||
|
{
|
||||||
|
$eventDate = $this->date;
|
||||||
|
|
||||||
|
if ($eventDate instanceof \Carbon\CarbonInterface) {
|
||||||
|
return $eventDate->copy()->addDay()->endOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return now()->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
public function getSettingsAttribute($value): array
|
public function getSettingsAttribute($value): array
|
||||||
{
|
{
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('events', 'live_show_token_expires_at')) {
|
||||||
|
$table->timestamp('live_show_token_expires_at')
|
||||||
|
->nullable()
|
||||||
|
->after('live_show_token_rotated_at');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('events', 'live_show_token_expires_at')) {
|
||||||
|
$table->dropColumn('live_show_token_expires_at');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -43,7 +43,6 @@ vi.mock('lucide-react', () => ({
|
|||||||
ListVideo: () => <span>list</span>,
|
ListVideo: () => <span>list</span>,
|
||||||
RefreshCcw: () => <span>refresh</span>,
|
RefreshCcw: () => <span>refresh</span>,
|
||||||
FlipHorizontal: () => <span>flip</span>,
|
FlipHorizontal: () => <span>flip</span>,
|
||||||
X: () => <span>close</span>,
|
|
||||||
Sparkles: () => <span>sparkles</span>,
|
Sparkles: () => <span>sparkles</span>,
|
||||||
Trophy: () => <span>trophy</span>,
|
Trophy: () => <span>trophy</span>,
|
||||||
Play: () => <span>play</span>,
|
Play: () => <span>play</span>,
|
||||||
@@ -53,6 +52,8 @@ vi.mock('lucide-react', () => ({
|
|||||||
ChevronLeft: () => <span>chevron-left</span>,
|
ChevronLeft: () => <span>chevron-left</span>,
|
||||||
ChevronRight: () => <span>chevron-right</span>,
|
ChevronRight: () => <span>chevron-right</span>,
|
||||||
QrCode: () => <span>qr</span>,
|
QrCode: () => <span>qr</span>,
|
||||||
|
Loader2: () => <span>loader</span>,
|
||||||
|
Maximize2: () => <span>maximize</span>,
|
||||||
Link: () => <span>link</span>,
|
Link: () => <span>link</span>,
|
||||||
Users: () => <span>users</span>,
|
Users: () => <span>users</span>,
|
||||||
Heart: () => <span>heart</span>,
|
Heart: () => <span>heart</span>,
|
||||||
|
|||||||
@@ -47,24 +47,27 @@ export default function ShareSheet({
|
|||||||
const { isDark } = useGuestThemeVariant();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||||
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
|
const inlineActive = variant === 'inline';
|
||||||
const [inlineMounted, setInlineMounted] = React.useState(false);
|
const [inlineMounted, setInlineMounted] = React.useState(false);
|
||||||
const [inlineVisible, setInlineVisible] = React.useState(false);
|
const [inlineVisible, setInlineVisible] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (variant !== 'inline') return;
|
if (!inlineActive) {
|
||||||
|
setInlineMounted(false);
|
||||||
|
setInlineVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
setInlineMounted(true);
|
setInlineMounted(true);
|
||||||
const raf = window.requestAnimationFrame(() => {
|
const frame = window.requestAnimationFrame(() => setInlineVisible(true));
|
||||||
setInlineVisible(true);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
});
|
|
||||||
return () => window.cancelAnimationFrame(raf);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setInlineVisible(false);
|
setInlineVisible(false);
|
||||||
const timeout = window.setTimeout(() => {
|
const timer = window.setTimeout(() => setInlineMounted(false), 260);
|
||||||
setInlineMounted(false);
|
return () => window.clearTimeout(timer);
|
||||||
}, 220);
|
}, [inlineActive, open]);
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [open, variant]);
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<YStack gap="$3">
|
<YStack gap="$3">
|
||||||
@@ -184,16 +187,14 @@ export default function ShareSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{url ? (
|
<Text fontSize="$1" color="$color" opacity={url ? 0.7 : 0} numberOfLines={1} style={{ minHeight: 16 }}>
|
||||||
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={1}>
|
{url ?? ' '}
|
||||||
{url}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (variant === 'inline') {
|
if (inlineActive) {
|
||||||
if (!inlineMounted) {
|
if (!inlineMounted && !open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,16 +204,16 @@ export default function ShareSheet({
|
|||||||
inset={0}
|
inset={0}
|
||||||
zIndex={20}
|
zIndex={20}
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
pointerEvents={open ? 'auto' : 'none'}
|
pointerEvents={inlineVisible ? 'auto' : 'none'}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
unstyled
|
unstyled
|
||||||
onPress={() => onOpenChange(false)}
|
onPress={() => onOpenChange(false)}
|
||||||
|
opacity={inlineVisible ? 1 : 0}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.5)' : 'rgba(15, 23, 42, 0.35)',
|
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.5)' : 'rgba(15, 23, 42, 0.35)',
|
||||||
opacity: inlineVisible ? 1 : 0,
|
|
||||||
transition: 'opacity 200ms ease',
|
transition: 'opacity 200ms ease',
|
||||||
}}
|
}}
|
||||||
aria-label={t('common.actions.close', 'Close')}
|
aria-label={t('common.actions.close', 'Close')}
|
||||||
@@ -222,9 +223,12 @@ export default function ShareSheet({
|
|||||||
backgroundColor="$surface"
|
backgroundColor="$surface"
|
||||||
borderTopLeftRadius="$6"
|
borderTopLeftRadius="$6"
|
||||||
borderTopRightRadius="$6"
|
borderTopRightRadius="$6"
|
||||||
|
opacity={inlineVisible ? 1 : 0}
|
||||||
style={{
|
style={{
|
||||||
transform: inlineVisible ? 'translateY(0)' : 'translateY(100%)',
|
backfaceVisibility: 'hidden',
|
||||||
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
|
transform: inlineVisible ? 'translate3d(0, 0, 0)' : 'translate3d(0, 56px, 0)',
|
||||||
|
transition: 'transform 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export default function GalleryScreen() {
|
|||||||
});
|
});
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||||
|
const [deleteConfirmMounted, setDeleteConfirmMounted] = React.useState(false);
|
||||||
|
const [deleteConfirmVisible, setDeleteConfirmVisible] = React.useState(false);
|
||||||
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
||||||
const touchStartX = React.useRef<number | null>(null);
|
const touchStartX = React.useRef<number | null>(null);
|
||||||
const fallbackAttemptedRef = React.useRef(false);
|
const fallbackAttemptedRef = React.useRef(false);
|
||||||
@@ -130,6 +132,26 @@ export default function GalleryScreen() {
|
|||||||
const [lightboxMounted, setLightboxMounted] = React.useState(false);
|
const [lightboxMounted, setLightboxMounted] = React.useState(false);
|
||||||
const [lightboxVisible, setLightboxVisible] = React.useState(false);
|
const [lightboxVisible, setLightboxVisible] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (deleteConfirmOpen) {
|
||||||
|
setDeleteConfirmVisible(false);
|
||||||
|
setDeleteConfirmMounted(true);
|
||||||
|
let frame1 = 0;
|
||||||
|
let frame2 = 0;
|
||||||
|
frame1 = window.requestAnimationFrame(() => {
|
||||||
|
frame2 = window.requestAnimationFrame(() => setDeleteConfirmVisible(true));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame1);
|
||||||
|
window.cancelAnimationFrame(frame2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteConfirmVisible(false);
|
||||||
|
const timer = window.setTimeout(() => setDeleteConfirmMounted(false), 220);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [deleteConfirmOpen]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
@@ -1334,7 +1356,7 @@ export default function GalleryScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
{deleteConfirmOpen ? (
|
{deleteConfirmMounted ? (
|
||||||
<YStack
|
<YStack
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top={0}
|
top={0}
|
||||||
@@ -1345,7 +1367,13 @@ export default function GalleryScreen() {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
padding="$4"
|
padding="$4"
|
||||||
backgroundColor={isDark ? 'rgba(2, 6, 23, 0.7)' : 'rgba(15, 23, 42, 0.4)'}
|
backgroundColor={isDark ? 'rgba(2, 6, 23, 0.7)' : 'rgba(15, 23, 42, 0.4)'}
|
||||||
style={{ backdropFilter: 'blur(6px)', zIndex: 6 }}
|
opacity={deleteConfirmVisible ? 1 : 0}
|
||||||
|
pointerEvents={deleteConfirmVisible ? 'auto' : 'none'}
|
||||||
|
style={{
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
zIndex: 6,
|
||||||
|
transition: 'opacity 200ms ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -1356,7 +1384,15 @@ export default function GalleryScreen() {
|
|||||||
backgroundColor="$surface"
|
backgroundColor="$surface"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={mutedButtonBorder}
|
borderColor={mutedButtonBorder}
|
||||||
style={{ boxShadow: cardShadow }}
|
opacity={deleteConfirmVisible ? 1 : 0}
|
||||||
|
style={{
|
||||||
|
boxShadow: cardShadow,
|
||||||
|
transform: deleteConfirmVisible
|
||||||
|
? 'translate3d(0, 0, 0) scale(1)'
|
||||||
|
: 'translate3d(0, 12px, 0) scale(0.98)',
|
||||||
|
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms ease',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<YStack gap="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$4" fontWeight="$7">
|
<Text fontSize="$4" fontWeight="$7">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { AlertCircle, Download } from 'lucide-react';
|
import { AlertCircle, Download, Maximize2, X } from 'lucide-react';
|
||||||
import StandaloneShell from '../components/StandaloneShell';
|
import StandaloneShell from '../components/StandaloneShell';
|
||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
import EventLogo from '../components/EventLogo';
|
import EventLogo from '../components/EventLogo';
|
||||||
@@ -14,6 +14,7 @@ import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
|||||||
import { mapEventBranding } from '../lib/eventBranding';
|
import { mapEventBranding } from '../lib/eventBranding';
|
||||||
import { BrandingTheme } from '../lib/brandingTheme';
|
import { BrandingTheme } from '../lib/brandingTheme';
|
||||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||||
|
|
||||||
interface ShareResponse {
|
interface ShareResponse {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -38,6 +39,7 @@ export default function SharedPhotoScreen() {
|
|||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
|
const [fullScreenOpen, setFullScreenOpen] = React.useState(false);
|
||||||
const branding = React.useMemo(() => {
|
const branding = React.useMemo(() => {
|
||||||
if (!state.data?.branding) {
|
if (!state.data?.branding) {
|
||||||
return null;
|
return null;
|
||||||
@@ -46,6 +48,7 @@ export default function SharedPhotoScreen() {
|
|||||||
}, [state.data]);
|
}, [state.data]);
|
||||||
const { isDark } = useGuestThemeVariant(branding);
|
const { isDark } = useGuestThemeVariant(branding);
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
|
const bento = getBentoSurfaceTokens(isDark);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -105,11 +108,21 @@ export default function SharedPhotoScreen() {
|
|||||||
const chips = buildChips(data, t);
|
const chips = buildChips(data, t);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<StandaloneShell>
|
<StandaloneShell compact>
|
||||||
<SurfaceCard glow>
|
<YStack gap="$3" style={{ paddingBottom: 'calc(var(--space-6) + 50px)' }}>
|
||||||
<XStack alignItems="center" gap="$3">
|
<YStack
|
||||||
|
padding="$4"
|
||||||
|
borderRadius="$card"
|
||||||
|
borderWidth={1}
|
||||||
|
borderBottomWidth={2}
|
||||||
|
borderColor={bento.borderColor}
|
||||||
|
borderBottomColor={bento.borderBottomColor}
|
||||||
|
backgroundColor={bento.backgroundColor}
|
||||||
|
style={{ boxShadow: bento.shadow }}
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" gap="$3" flexWrap="wrap">
|
||||||
<EventLogo name={data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} size="s" />
|
<EventLogo name={data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} size="s" />
|
||||||
<YStack gap="$1">
|
<YStack gap="$1" flex={1} minWidth={180}>
|
||||||
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
|
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
|
||||||
{t('share.title', 'Geteiltes Foto')}
|
{t('share.title', 'Geteiltes Foto')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -119,33 +132,61 @@ export default function SharedPhotoScreen() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
{data.photo.title ? (
|
{data.photo.title ? (
|
||||||
<Text fontSize="$3" color={mutedText} marginTop="$1">
|
<Text fontSize="$3" color={mutedText} marginTop="$2" numberOfLines={2}>
|
||||||
{data.photo.title}
|
{data.photo.title}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</SurfaceCard>
|
</YStack>
|
||||||
|
|
||||||
<SurfaceCard padding={0} overflow="hidden">
|
|
||||||
<YStack
|
<YStack
|
||||||
height={360}
|
padding="$2"
|
||||||
|
borderRadius="$card"
|
||||||
|
borderWidth={1}
|
||||||
|
borderBottomWidth={2}
|
||||||
|
borderColor={bento.borderColor}
|
||||||
|
borderBottomColor={bento.borderBottomColor}
|
||||||
|
backgroundColor={bento.backgroundColor}
|
||||||
|
style={{ boxShadow: bento.shadow }}
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
borderRadius="$card"
|
||||||
|
overflow="hidden"
|
||||||
|
position="relative"
|
||||||
style={{
|
style={{
|
||||||
|
height: 'min(45vh, 320px)',
|
||||||
|
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.4)' : 'rgba(15, 23, 42, 0.06)',
|
||||||
backgroundImage: `url(${data.photo.image_urls.full})`,
|
backgroundImage: `url(${data.photo.image_urls.full})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</SurfaceCard>
|
<Button
|
||||||
|
size="$3"
|
||||||
|
circular
|
||||||
|
position="absolute"
|
||||||
|
top="$2"
|
||||||
|
right="$2"
|
||||||
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.85)'}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={bento.borderColor}
|
||||||
|
onPress={() => setFullScreenOpen(true)}
|
||||||
|
aria-label={t('share.fullscreen', 'Fullscreen')}
|
||||||
|
>
|
||||||
|
<Maximize2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
|
</Button>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
{chips.length > 0 ? (
|
{chips.length > 0 ? (
|
||||||
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
|
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
|
||||||
{chips.map((chip) => (
|
{chips.map((chip) => (
|
||||||
<SurfaceCard key={chip.id} padding="$2" borderRadius="$pill">
|
<SurfaceCard key={chip.id} padding="$2" borderRadius="$pill" minWidth={120}>
|
||||||
<XStack alignItems="center" gap="$2">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
||||||
{chip.icon ? <Text fontSize="$3">{chip.icon}</Text> : null}
|
{chip.icon ? <Text fontSize="$3">{chip.icon}</Text> : null}
|
||||||
<Text fontSize="$2" color={mutedText}>
|
<Text fontSize="$2" color={mutedText}>
|
||||||
{chip.label}
|
{chip.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$2" fontWeight="$7">
|
<Text fontSize="$2" fontWeight="$7" numberOfLines={2} style={{ maxWidth: 220 }}>
|
||||||
{chip.value}
|
{chip.value}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -154,17 +195,95 @@ export default function SharedPhotoScreen() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<YStack style={{ position: 'sticky', bottom: 12 }}>
|
||||||
<Button
|
<Button
|
||||||
size="$4"
|
size="$4"
|
||||||
borderRadius="$pill"
|
borderRadius="$pill"
|
||||||
backgroundColor="$primary"
|
backgroundColor="$primary"
|
||||||
onPress={() => window.open(data.photo.image_urls.full, '_blank')}
|
onPress={() => window.open(data.photo.image_urls.full, '_blank')}
|
||||||
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
|
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
|
||||||
<Download size={18} color="white" />
|
<Download size={18} color="white" />
|
||||||
<Text fontSize="$3" fontWeight="$7" color="white">
|
<Text fontSize="$3" fontWeight="$7" color="white">
|
||||||
{t('galleryPublic.download', 'Download')}
|
{t('galleryPublic.download', 'Download')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</XStack>
|
||||||
</Button>
|
</Button>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
{fullScreenOpen ? (
|
||||||
|
<YStack
|
||||||
|
position="fixed"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
zIndex={3000}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(2, 6, 23, 0.8)' : 'rgba(15, 23, 42, 0.45)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
unstyled
|
||||||
|
onPress={() => setFullScreenOpen(false)}
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
aria-label={t('common.actions.close', 'Close')}
|
||||||
|
/>
|
||||||
|
<YStack
|
||||||
|
width="92vw"
|
||||||
|
height="82vh"
|
||||||
|
maxWidth={920}
|
||||||
|
maxHeight={720}
|
||||||
|
borderRadius="$bentoLg"
|
||||||
|
overflow="hidden"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={bento.borderColor}
|
||||||
|
style={{ position: 'relative', boxShadow: bento.shadow }}
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${data.photo.image_urls.full})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: 'blur(26px)',
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$4" style={{ zIndex: 1 }}>
|
||||||
|
<img
|
||||||
|
src={data.photo.image_urls.full}
|
||||||
|
alt={t('galleryPage.photo.alt', { id: data.photo.id, suffix: '' }, `Foto ${data.photo.id}`)}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
circular
|
||||||
|
position="absolute"
|
||||||
|
top="$3"
|
||||||
|
right="$3"
|
||||||
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.85)'}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={bento.borderColor}
|
||||||
|
onPress={() => setFullScreenOpen(false)}
|
||||||
|
aria-label={t('common.actions.close', 'Close')}
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
|
</Button>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
</StandaloneShell>
|
</StandaloneShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Models\Tenant;
|
|||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PhotoShareLinkTest extends TestCase
|
class PhotoShareLinkTest extends TestCase
|
||||||
@@ -64,13 +65,18 @@ class PhotoShareLinkTest extends TestCase
|
|||||||
|
|
||||||
public function test_share_payload_exposes_public_photo_data(): void
|
public function test_share_payload_exposes_public_photo_data(): void
|
||||||
{
|
{
|
||||||
|
Config::set('filesystems.default', 'public');
|
||||||
|
Storage::fake('public');
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
$task = Task::factory()->for($tenant)->create();
|
$task = Task::factory()->for($tenant)->create();
|
||||||
$photo = Photo::factory()->for($event)->create([
|
$photo = Photo::factory()->for($event)->create([
|
||||||
'status' => 'approved',
|
'status' => 'approved',
|
||||||
'task_id' => $task->id,
|
'task_id' => $task->id,
|
||||||
|
'file_path' => 'photos/share-test.jpg',
|
||||||
|
'thumbnail_path' => 'photos/thumbnails/share-test.jpg',
|
||||||
]);
|
]);
|
||||||
|
Storage::disk('public')->put('photos/share-test.jpg', 'photo');
|
||||||
|
|
||||||
$share = PhotoShareLink::factory()->for($photo)->create([
|
$share = PhotoShareLink::factory()->for($photo)->create([
|
||||||
'expires_at' => now()->addDay(),
|
'expires_at' => now()->addDay(),
|
||||||
@@ -95,5 +101,16 @@ class PhotoShareLinkTest extends TestCase
|
|||||||
'buttons',
|
'buttons',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$assetUrl = $response->json('photo.image_urls.full');
|
||||||
|
$this->assertIsString($assetUrl);
|
||||||
|
$this->assertNotNull(parse_url($assetUrl, PHP_URL_SCHEME));
|
||||||
|
|
||||||
|
$parsed = parse_url($assetUrl);
|
||||||
|
$path = (string) ($parsed['path'] ?? '');
|
||||||
|
$query = $parsed['query'] ?? null;
|
||||||
|
|
||||||
|
$assetResponse = $this->get($path.($query ? "?{$query}" : ''));
|
||||||
|
$assetResponse->assertOk();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class LiveShowDataModelTest extends TestCase
|
|||||||
|
|
||||||
public function test_event_can_ensure_and_rotate_live_show_token(): void
|
public function test_event_can_ensure_and_rotate_live_show_token(): void
|
||||||
{
|
{
|
||||||
$event = Event::factory()->create();
|
$eventDate = now()->addDays(1)->startOfDay();
|
||||||
|
$event = Event::factory()->create(['date' => $eventDate]);
|
||||||
|
|
||||||
$token = $event->ensureLiveShowToken();
|
$token = $event->ensureLiveShowToken();
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class LiveShowDataModelTest extends TestCase
|
|||||||
$this->assertSame(64, strlen($token));
|
$this->assertSame(64, strlen($token));
|
||||||
$this->assertSame($token, $event->refresh()->live_show_token);
|
$this->assertSame($token, $event->refresh()->live_show_token);
|
||||||
$this->assertNotNull($event->live_show_token_rotated_at);
|
$this->assertNotNull($event->live_show_token_rotated_at);
|
||||||
|
$this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String());
|
||||||
|
|
||||||
$rotated = $event->rotateLiveShowToken();
|
$rotated = $event->rotateLiveShowToken();
|
||||||
|
|
||||||
@@ -30,6 +32,24 @@ class LiveShowDataModelTest extends TestCase
|
|||||||
$this->assertSame(64, strlen($rotated));
|
$this->assertSame(64, strlen($rotated));
|
||||||
$this->assertNotSame($token, $rotated);
|
$this->assertNotSame($token, $rotated);
|
||||||
$this->assertSame($rotated, $event->refresh()->live_show_token);
|
$this->assertSame($rotated, $event->refresh()->live_show_token);
|
||||||
|
$this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_live_show_token_expiry_updates_when_event_date_changes(): void
|
||||||
|
{
|
||||||
|
$eventDate = now()->addDays(3)->startOfDay();
|
||||||
|
$event = Event::factory()->create(['date' => $eventDate]);
|
||||||
|
|
||||||
|
$event->ensureLiveShowToken();
|
||||||
|
$event->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String());
|
||||||
|
|
||||||
|
$newDate = now()->addDays(7)->startOfDay();
|
||||||
|
$event->update(['date' => $newDate]);
|
||||||
|
|
||||||
|
$event->refresh();
|
||||||
|
$this->assertSame($newDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_photo_live_status_is_cast_and_defaults_to_none(): void
|
public function test_photo_live_status_is_cast_and_defaults_to_none(): void
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ class LiveShowLinkControllerTest extends TenantTestCase
|
|||||||
{
|
{
|
||||||
public function test_live_show_link_response_includes_qr_code_and_url(): void
|
public function test_live_show_link_response_includes_qr_code_and_url(): void
|
||||||
{
|
{
|
||||||
|
$eventDate = now()->addDays(2)->startOfDay();
|
||||||
$event = Event::factory()
|
$event = Event::factory()
|
||||||
->for($this->tenant)
|
->for($this->tenant)
|
||||||
->create([
|
->create([
|
||||||
'name' => ['de' => 'Live-Show Test', 'en' => 'Live Show Test'],
|
'name' => ['de' => 'Live-Show Test', 'en' => 'Live Show Test'],
|
||||||
'slug' => 'live-show-link-test',
|
'slug' => 'live-show-link-test',
|
||||||
|
'date' => $eventDate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link");
|
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link");
|
||||||
@@ -26,15 +28,18 @@ class LiveShowLinkControllerTest extends TenantTestCase
|
|||||||
$this->assertArrayHasKey('url', $data);
|
$this->assertArrayHasKey('url', $data);
|
||||||
$this->assertArrayHasKey('qr_code_data_url', $data);
|
$this->assertArrayHasKey('qr_code_data_url', $data);
|
||||||
$this->assertArrayHasKey('rotated_at', $data);
|
$this->assertArrayHasKey('rotated_at', $data);
|
||||||
|
$this->assertArrayHasKey('expires_at', $data);
|
||||||
|
|
||||||
$this->assertIsString($data['token']);
|
$this->assertIsString($data['token']);
|
||||||
$this->assertIsString($data['url']);
|
$this->assertIsString($data['url']);
|
||||||
$this->assertIsString($data['qr_code_data_url']);
|
$this->assertIsString($data['qr_code_data_url']);
|
||||||
$this->assertStringStartsWith('data:image/png;base64,', $data['qr_code_data_url']);
|
$this->assertStringStartsWith('data:image/png;base64,', $data['qr_code_data_url']);
|
||||||
$this->assertNotNull($data['rotated_at']);
|
$this->assertNotNull($data['rotated_at']);
|
||||||
|
$this->assertNotNull($data['expires_at']);
|
||||||
|
|
||||||
$expectedBase = rtrim((string) config('app.url'), '/');
|
$expectedBase = rtrim((string) config('app.url'), '/');
|
||||||
$this->assertSame("{$expectedBase}/show/{$data['token']}", $data['url']);
|
$this->assertSame("{$expectedBase}/show/{$data['token']}", $data['url']);
|
||||||
|
$this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $data['expires_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_rotate_live_show_link_changes_token(): void
|
public function test_rotate_live_show_link_changes_token(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user