Initial Commit
linter / quality (push) Canceled after 0s
tests / ci (8.3) (push) Canceled after 0s
tests / ci (8.4) (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 0s

This commit is contained in:
2026-08-28 21:50:26 +10:00
commit d8d4ecbe8a
142 changed files with 25234 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { createInertiaApp } from '@inertiajs/vue3';
import { initializeTheme } from '@/composables/useAppearance';
import AppLayout from '@/layouts/AppLayout.vue';
import '@mdi/font/css/materialdesignicons.css';
import { createApp, h } from 'vue';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import '@fontsource/space-grotesk/400.css';
import '@fontsource/space-grotesk/500.css';
import '@fontsource/space-grotesk/600.css';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const vuetify = createVuetify({
components,
directives,
});
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: () => {
switch (true) {
default:
return AppLayout;
}
},
setup({ el, App, props, plugin }) {
if (!el) return;
createApp({ render: () => h(App, props) })
.use(plugin)
.use(vuetify)
.mount(el);
},
progress: {
color: '#4B5563',
},
});
// This will set light / dark mode on page load...
initializeTheme();
+59
View File
@@ -0,0 +1,59 @@
<!-- Avatar.vue -->
<script setup lang="ts">
import { proxyImage } from '@/lib/proxyImage';
withDefaults(
defineProps<{
size?: 'sm' | 'md' | 'lg';
src?: string | null;
alt?: string;
}>(),
{
size: 'md',
src: null,
alt: '',
},
);
</script>
<template>
<span class="avatar" :class="`avatar--${size}`">
<img
v-if="src"
:src="proxyImage(src)"
:alt="alt"
class="avatar__img"
referrerpolicy="no-referrer"
loading="lazy"
/>
</span>
</template>
<style scoped>
.avatar {
display: block;
border-radius: 999px;
background: radial-gradient(circle at 35% 30%, #24485a, var(--midwater));
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar--sm {
width: 1.75rem;
height: 1.75rem;
}
.avatar--md {
width: 2.1rem;
height: 2.1rem;
}
.avatar--lg {
width: 2.75rem;
height: 2.75rem;
}
</style>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { Post } from '@/types/dredge.types';
import PostCard from './PostCard.vue';
const posts = ref<Post[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
async function fetchPosts() {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/posts');
if (!response.ok) {
throw new Error('Failed to load posts');
}
posts.value = await response.json();
} catch (e) {
error.value = 'Could not load your feed. Please try again.';
} finally {
loading.value = false;
}
}
function onLike(id: string) {
console.log('like', id);
}
onMounted(fetchPosts);
</script>
<template>
<section class="feed" >
<p v-if="loading">Loading your feed</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<p v-else-if="posts.length === 0">No posts to show yet.</p>
<PostCard
v-for="post in posts"
v-else
:key="post.id"
:post="post"
/>
</section>
</template>
<style scoped>
.feed {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
@@ -0,0 +1,353 @@
<script setup lang="ts">
import Avatar from '@/components/dredge/Avatar.vue';
import type { Post } from '@/types/dredge.types';
import { usePauseOffscreen } from '@/composables/usePauseOffScreen';
import { ComponentPublicInstance, ref } from 'vue';
const props = defineProps<{
post: Post;
}>();
const displayCaption =
props.post.caption ?? props.post.accessibility_caption ?? '';
const imageAlt =
props.post.accessibility_caption ??
(props.post.caption
? `Post by ${props.post.username}: ${props.post.caption}`
: `Photo posted by ${props.post.username}`);
const carouselSlides =
props.post.is_carousel && props.post.carousel_images
? props.post.carousel_images.map((item, i) => ({
...item,
alt:
item.accessibility_caption ??
`Slide ${i + 1} of ${props.post.carousel_images!.length} from ${props.post.username}`,
}))
: [];
const videoRefs = ref<HTMLVideoElement[]>([]);
function registerVideo(el: Element | ComponentPublicInstance | null) {
if (el instanceof HTMLVideoElement) {
videoRefs.value.push(el);
}
}
usePauseOffscreen(videoRefs);
</script>
<template>
<article class="card">
<header class="card__head">
<Avatar
size="md"
:src="post.profile_pic_url"
:alt="`${post.username}'s profile picture`"
/>
<div class="card__who">
<span class="card__handle">{{ post.username }}</span>
<span v-if="post.taken_at" class="card__depth">{{
post.taken_at_date
}}</span>
</div>
<a
v-if="post.permalink"
:href="post.permalink"
target="_blank"
rel="noopener noreferrer"
class="icon-btn icon-btn--ghost"
aria-label="Open on Instagram"
>
<v-icon icon="mdi-open-in-new" size="18" />
</a>
</header>
<div class="card__specimen">
<!-- Carousel: multiple images/videos -->
<v-carousel
v-if="post.is_carousel && carouselSlides.length"
height="500"
hide-delimiter-background
:show-arrows="carouselSlides.length > 1"
:cycle="false"
:continuous="false"
color="#fff"
:style="{ '--slide-count': carouselSlides.length }"
>
<v-carousel-item v-for="(slide, i) in carouselSlides" :key="i">
<video
v-if="slide.video_url"
:src="slide.video_url"
:aria-label="slide.alt"
:ref="registerVideo"
controls
preload="metadata"
playsinline
class="card__video"
>
Your browser doesn't support video playback.
</video>
<v-img
v-else-if="slide.url"
:src="slide.url"
:alt="slide.alt"
contain
height="100%"
width="100%"
>
<template #placeholder>
<div class="card__scan" />
</template>
<template #error>
<div class="card__img-fallback">
<v-icon
icon="mdi-image-broken-variant"
size="32"
/>
<span>Image unavailable</span>
</div>
</template>
</v-img>
<div v-else class="card__scan" />
</v-carousel-item>
</v-carousel>
<!-- Single video -->
<video
v-else-if="post.is_video && post.video_url"
:src="post.video_url"
:aria-label="imageAlt"
:ref="registerVideo"
:poster="post.image_url ?? undefined"
controls
preload="metadata"
playsinline
class="card__video"
>
Your browser doesn't support video playback.
</video>
<!-- Single image -->
<v-img
v-else-if="post.image_url"
:src="post.image_url"
:alt="imageAlt"
contain
height="100%"
width="100%"
>
<template #placeholder>
<div class="card__scan" />
</template>
<template #error>
<div class="card__img-fallback">
<v-icon icon="mdi-image-broken-variant" size="32" />
<span>Image unavailable</span>
</div>
</template>
</v-img>
<div v-else class="card__scan" />
<v-chip
v-if="post.is_carousel && carouselSlides.length"
class="card__badge"
size="small"
prepend-icon="mdi-image-multiple"
:aria-label="`This post has ${carouselSlides.length} items`"
>
{{ carouselSlides.length }}
</v-chip>
</div>
<footer class="card__foot">
<p v-if="post.like_count" class="card__likes">
{{ post.like_count }}
{{ post.like_count === 1 ? 'like' : 'likes' }}
</p>
<p v-if="displayCaption" class="card__caption">
<span class="card__handle">{{ post.username }}</span>
{{ displayCaption }}
</p>
</footer>
</article>
</template>
<style scoped>
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.card__head {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
}
.card__who {
display: flex;
flex-direction: column;
line-height: 1.25;
flex: 1;
}
.card__handle {
font-size: 0.88rem;
font-weight: 600;
color: var(--sediment);
}
.card__depth {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
color: var(--haze);
}
.card__specimen {
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
background:
repeating-linear-gradient(
0deg,
rgba(76, 224, 210, 0.05) 0px,
rgba(76, 224, 210, 0.05) 1px,
transparent 1px,
transparent 4px
),
linear-gradient(160deg, #163041, #081722 65%);
position: relative;
}
.card__scan {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
transparent,
rgba(76, 224, 210, 0.12),
transparent
);
background-size: 100% 260%;
animation: scan 6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.card__scan {
animation: none;
}
}
@keyframes scan {
0% {
background-position: 0 -60%;
}
100% {
background-position: 0 160%;
}
}
.card__img-fallback {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
color: var(--haze);
font-size: 0.8rem;
}
.card__badge {
position: absolute;
top: 0.6rem;
right: 0.6rem;
}
.card__foot {
padding: 0.85rem 1rem 1.1rem;
}
.card__likes {
font-size: 0.85rem;
font-weight: 600;
color: var(--sediment);
margin-bottom: 0.35rem;
}
.card__caption {
font-size: 0.85rem;
line-height: 1.5;
color: var(--sediment-dim);
}
.card__caption .card__handle {
margin-right: 0.4rem;
}
.icon-btn {
width: 2.1rem;
height: 2.1rem;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--midwater);
color: var(--sediment);
font-size: 1rem;
cursor: pointer;
text-decoration: none;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.icon-btn:hover,
.icon-btn:focus-visible {
border-color: var(--bioluminum);
color: var(--bioluminum);
}
.icon-btn:focus-visible {
outline: 2px solid var(--bioluminum);
outline-offset: 2px;
}
.icon-btn--ghost {
background: transparent;
border-color: transparent;
}
.card__video {
width: 100%;
max-height: 600px;
object-fit: contain;
background: #000;
display: block;
}
/* Delimiter row: allow it to fit or scroll instead of clipping */
:deep(.v-carousel__controls) {
background: transparent;
padding: 4px 10px;
gap: 10px;
flex-wrap: nowrap;
overflow-x: auto;
justify-content: safe center; /* was: flex-start */
scrollbar-width: none;
}
:deep(.v-carousel__controls)::-webkit-scrollbar {
display: none;
}
/* Shrink dots as slide count grows, so 20 items fit without scrolling */
:deep(.v-carousel__controls__item) {
width: clamp(6px, calc(220px / var(--slide-count, 1)), 10px) !important;
height: clamp(6px, calc(220px / var(--slide-count, 1)), 10px) !important;
min-width: 0 !important;
margin: 0 !important;
opacity: 1 !important;
}
/* Inactive dots: much higher contrast than Vuetify's default */
:deep(.v-carousel__controls__item .v-icon) {
color: var(--sediment) !important;
opacity: 0.4;
}
/* Active dot: full opacity + accent color (via the color prop above) */
:deep(.v-carousel__controls__item.v-btn--active .v-icon),
:deep(.v-carousel__controls__item[aria-pressed='true'] .v-icon) {
opacity: 1;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts"></script>
<template>
<header class="topbar">
<span class="wordmark">Dredge<em>Gram</em></span>
<span class="icons">
<button class="icon-btn" aria-label="Notifications">
<span class="mdi mdi-heart"></span>
</button>
<button class="icon-btn" aria-label="Messages">
<span class="mdi mdi-message"></span>
</button>
</span>
</header>
</template>
<style scoped>
.wordmark {
font-family: 'Space Grotesk', 'IBM Plex Sans', system-ui, sans-serif;
font-weight: 600;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.wordmark em {
font-style: normal;
color: var(--bioluminum);
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.5rem;
background: rgba(5, 11, 20, 0.9);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.icons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border: none;
background: transparent;
border-radius: 50%;
color: inherit;
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.1s ease;
-webkit-tap-highlight-color: transparent;
}
.icon-btn .mdi {
font-size: 1.5rem;
line-height: 1;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.icon-btn:active {
background: rgba(255, 255, 255, 0.14);
transform: scale(0.94);
}
@media (max-width: 480px) {
.topbar {
padding: 0.75rem 1rem;
}
.icons {
gap: 0;
}
.icon-btn {
width: 2.9rem;
height: 2.9rem;
}
.icon-btn .mdi {
font-size: 1.6rem;
}
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import Avatar from '@/components/dredge/Avatar.vue';
defineProps<{
handle: string;
role: string;
}>();
</script>
<template>
<div class="sidebar__profile">
<Avatar size="lg" />
<div>
<span class="card__handle">{{ handle }}</span>
</div>
</div>
</template>
<style scoped>
.sidebar__profile {
display: flex;
align-items: center;
gap: 0.75rem;
}
.sidebar__profile > div {
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,54 @@
<!-- Sidebar.vue -->
<script setup lang="ts">
import ProfileSummary from './ProfileSummary.vue';
import SuggestionRow from './SuggestionRow.vue';
const catches = [
{ id: 1, tag: 'surface' },
{ id: 2, tag: 'midwater' },
{ id: 3, tag: 'trench' },
{ id: 4, tag: 'vent' },
{ id: 5, tag: 'benthic' },
];
function follow(id: number) {
console.log('follow', id);
}
</script>
<template>
<aside class="sidebar">
<ProfileSummary handle="generictravelphotos" role="crew member" />
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.75rem;
position: sticky;
top: 5.5rem;
}
.sidebar__suggest {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.sidebar__suggest-title {
font-size: 0.75rem;
color: var(--haze);
text-transform: lowercase;
letter-spacing: 0.03em;
}
@media (max-width: 860px) {
.sidebar {
position: static;
flex-direction: row;
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,46 @@
<!-- SuggestionRow.vue -->
<script setup lang="ts">
import Avatar from '@/components/dredge/Avatar.vue';
defineProps<{
handle: string;
following?: boolean;
}>();
defineEmits<{
follow: [];
}>();
</script>
<template>
<div class="sidebar__suggest-row">
<Avatar size="sm" />
<span class="card__handle">{{ handle }}</span>
<button class="follow-btn" @click="$emit('follow')">
{{ following ? 'following' : 'follow' }}
</button>
</div>
</template>
<style scoped>
.sidebar__suggest-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.sidebar__suggest-row .card__handle {
flex: 1;
font-size: 0.8rem;
}
.follow-btn {
background: none;
border: none;
color: var(--bioluminum);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.follow-btn:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,83 @@
<!-- StoriesRail.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import StoryRing from './StoryRing.vue';
import StoryViewer from './StoryViewer.vue';
import type { StoryLink } from '@/types/dredge.types';
const stories = ref<StoryLink[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const openUsername = ref<string | null>(null);
async function fetchStories() {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/stories');
if (!response.ok) throw new Error('Failed to load stories');
stories.value = await response.json();
} catch (e) {
error.value = 'Could not load stories.';
} finally {
loading.value = false;
}
}
function openStory(username: string) {
openUsername.value = username;
}
async function closeStory() {
openUsername.value = null;
await fetchStories();
}
onMounted(fetchStories);
</script>
<template>
<section class="rail" aria-label="Surface catches">
<p v-if="loading" class="rail__status">Loading stories</p>
<p v-else-if="error" class="rail__status" role="alert">{{ error }}</p>
<p v-else-if="stories.length === 0" class="rail__status">
No active stories.
</p>
<template v-else>
<StoryRing
v-for="s in stories"
:key="s.username"
:username="s.username"
:profile-pic-url="s.profile_pic_url"
:is-unread="s.is_unread"
:muted="s.muted"
@select="openStory(s.username)"
/>
</template>
</section>
<StoryViewer
v-if="openUsername"
:username="openUsername"
@close="closeStory"
/>
</template>
<style scoped>
.rail {
display: flex;
gap: 1.1rem;
overflow-x: auto;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--line);
min-width: 0; /* add */
width: 100%; /* add */
}
.rail__status {
font-size: 0.8rem;
color: var(--haze);
padding: 0.5rem 0;
}
</style>
@@ -0,0 +1,111 @@
<!-- StoryRing.vue -->
<script setup lang="ts">
import { proxyImage } from '@/lib/proxyImage';
const props = defineProps<{
username: string;
profilePicUrl: string | null;
isUnread: boolean;
muted: boolean;
}>();
defineEmits<{
select: [];
}>();
</script>
<template>
<button
class="rail__item"
:class="{ 'rail__item--muted': muted }"
@click="$emit('select')"
:aria-label="`${username}'s story, ${isUnread ? 'unread' : 'already viewed'}${muted ? ', muted' : ''}`"
>
<span
class="rail__ring"
:class="isUnread ? 'rail__ring--unread' : 'rail__ring--seen'"
>
<img
v-if="profilePicUrl"
:src="proxyImage(profilePicUrl)"
:alt="''"
class="rail__thumb-img"
loading="lazy"
/>
<span v-else class="rail__thumb" />
</span>
<span class="rail__label">{{ username }}</span>
</button>
</template>
<style scoped>
.rail__item--muted .rail__ring{
opacity: 0.26;
}
.rail__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
background: none;
border: none;
cursor: pointer;
color: var(--haze);
flex-shrink: 0; /* add — keeps rings from squashing, forces scroll instead */
}
.rail__ring {
width: 4rem;
height: 4rem;
border-radius: 999px;
display: grid;
place-items: center;
overflow: hidden;
}
@media (max-width: 480px) {
.rail__ring {
width: 5rem;
height: 5rem;
}
.rail__thumb {
width: 4.35rem;
height: 4.35rem;
}
.rail__label {
font-size: 0.75rem;
max-width: 5.2rem;
}
}
.rail__ring--unread {
border: 1.5px solid var(--bioluminum);
box-shadow: 0 0 10px -2px var(--bioluminum);
}
.rail__ring--seen {
border: 1.5px solid var(--line);
box-shadow: none;
}
.rail__thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 999px;
}
.rail__thumb {
width: 3.35rem;
height: 3.35rem;
border-radius: 999px;
background: radial-gradient(circle at 35% 30%, #1d3644, var(--midwater));
}
.rail__label {
font-size: 0.7rem;
letter-spacing: 0.03em;
text-transform: lowercase;
max-width: 4.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,331 @@
<!-- StoryViewer.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import { proxyImage } from '@/lib/proxyImage';
import type { StoryDetail } from '@/types/dredge.types';
const props = defineProps<{
username: string;
}>();
const emit = defineEmits<{
close: [];
}>();
const detail = ref<StoryDetail | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const currentIndex = ref(0);
const progress = ref(0); // 0-1 for the current item
const IMAGE_DURATION_MS = 5000;
let timer: ReturnType<typeof setInterval> | null = null;
let elapsed = 0;
const currentItem = computed(
() => detail.value?.items[currentIndex.value] ?? null,
);
async function fetchStory() {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/stories/${props.username}`);
if (!response.ok) throw new Error('Failed to load story');
detail.value = await response.json();
currentIndex.value = detail.value?.start_index ?? 0;
} catch (e) {
error.value = 'Could not load this story.';
} finally {
loading.value = false;
}
}
function markViewed(storyId: string) {
// fire-and-forget, don't block the viewer on this
fetch(`/api/story-items/${storyId}/view`, { method: 'POST' }).catch(
() => {},
);
}
function startTimer() {
clearTimer();
elapsed = 0;
progress.value = 0;
// Videos advance on their own 'ended' event instead of a fixed timer
if (currentItem.value?.is_video) return;
const tick = 100;
timer = setInterval(() => {
elapsed += tick;
progress.value = Math.min(elapsed / IMAGE_DURATION_MS, 1);
if (elapsed >= IMAGE_DURATION_MS) {
next();
}
}, tick);
}
function clearTimer() {
if (timer) clearInterval(timer);
timer = null;
}
function next() {
if (!detail.value) return;
if (currentIndex.value < detail.value.items.length - 1) {
currentIndex.value++;
} else {
emit('close');
}
}
function prev() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
function onVideoEnded() {
next();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowRight') next();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'Escape') emit('close');
}
watch(currentIndex, () => {
startTimer();
if (currentItem.value) markViewed(currentItem.value.id);
});
onMounted(async () => {
await fetchStory();
if (detail.value?.items.length) {
startTimer();
if (currentItem.value) markViewed(currentItem.value.id);
}
window.addEventListener('keydown', onKeydown);
});
onUnmounted(() => {
clearTimer();
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<div
class="viewer"
role="dialog"
aria-modal="true"
:aria-label="`${username}'s story`"
>
<div class="viewer__backdrop" @click="emit('close')" />
<div class="viewer__content">
<p v-if="loading" class="viewer__status">Loading story</p>
<p v-else-if="error" class="viewer__status" role="alert">
{{ error }}
</p>
<template v-else-if="currentItem">
<div class="viewer__progress" role="presentation">
<div
v-for="(item, i) in detail!.items"
:key="item.id"
class="viewer__segment"
>
<div
class="viewer__segment-fill"
:style="{
width:
i < currentIndex
? '100%'
: i === currentIndex
? `${progress * 100}%`
: '0%',
}"
/>
</div>
</div>
<header class="viewer__header">
<span class="viewer__username">{{ username }}</span>
<span class="viewer__count"
>{{ currentIndex + 1 }} /
{{ detail!.items.length }}</span
>
<button
class="viewer__close"
@click="emit('close')"
aria-label="Close story"
>
</button>
</header>
<div class="viewer__media">
<video
v-if="currentItem.is_video && currentItem.video_url"
:key="currentItem.id"
:src="currentItem.video_url ?? undefined"
:aria-label="
currentItem.accessibility_caption ??
`Story video from ${username}`
"
autoplay
playsinline
@ended="onVideoEnded"
class="viewer__video"
>
Your browser doesn't support video playback.
</video>
<img
v-else-if="currentItem.image_url"
:src="proxyImage(currentItem.image_url) ?? undefined"
:alt="
currentItem.accessibility_caption ??
`Story photo from ${username}`
"
class="viewer__image"
/>
</div>
<nav class="viewer__nav" aria-label="Story navigation">
<button
class="viewer__nav-btn"
:disabled="currentIndex === 0"
@click="prev"
aria-label="Previous story"
>
</button>
<button
class="viewer__nav-btn"
@click="next"
aria-label="Next story"
>
</button>
</nav>
</template>
</div>
</div>
</template>
<style scoped>
.viewer {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.viewer__backdrop {
position: absolute;
inset: 0;
background: rgba(4, 12, 16, 0.92);
}
.viewer__content {
position: relative;
width: min(420px, 92vw);
height: min(760px, 88vh);
background: #000;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer__status {
margin: auto;
color: var(--haze);
}
.viewer__progress {
display: flex;
gap: 0.3rem;
padding: 0.6rem 0.6rem 0;
z-index: 2;
}
.viewer__segment {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.25);
border-radius: 2px;
overflow: hidden;
}
.viewer__segment-fill {
height: 100%;
background: #fff;
transition: width 0.1s linear;
}
.viewer__header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
color: #fff;
z-index: 2;
}
.viewer__username {
font-weight: 600;
font-size: 0.85rem;
}
.viewer__count {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
.viewer__close {
margin-left: auto;
background: none;
border: none;
color: #fff;
font-size: 1.1rem;
cursor: pointer;
padding: 0.3rem;
}
.viewer__media {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.viewer__image,
.viewer__video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.viewer__nav {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.4rem;
pointer-events: none;
}
.viewer__nav-btn {
pointer-events: auto;
background: rgba(0, 0, 0, 0.35);
border: none;
color: #fff;
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
font-size: 1.3rem;
cursor: pointer;
}
.viewer__nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.viewer__segment-fill {
transition: none;
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = {
appearance: Ref<Appearance>;
resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void;
};
export function updateTheme(value: Appearance): void {
if (typeof window === 'undefined') {
return;
}
if (value === 'system') {
const mediaQueryList = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
document.documentElement.classList.toggle(
'dark',
systemTheme === 'dark',
);
} else {
document.documentElement.classList.toggle('dark', value === 'dark');
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('appearance') as Appearance | null;
};
const prefersDark = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const handleSystemThemeChange = () => {
const currentAppearance = getStoredAppearance();
updateTheme(currentAppearance || 'system');
};
export function initializeTheme(): void {
if (typeof window === 'undefined') {
return;
}
// Initialize theme from saved preference or default to system...
const savedAppearance = getStoredAppearance();
updateTheme(savedAppearance || 'system');
// Set up system theme change listener...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
const appearance = ref<Appearance>('system');
export function useAppearance(): UseAppearanceReturn {
onMounted(() => {
const savedAppearance = localStorage.getItem(
'appearance',
) as Appearance | null;
if (savedAppearance) {
appearance.value = savedAppearance;
}
});
const resolvedAppearance = computed<ResolvedAppearance>(() => {
if (appearance.value === 'system') {
return prefersDark() ? 'dark' : 'light';
}
return appearance.value;
});
function updateAppearance(value: Appearance) {
appearance.value = value;
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', value);
// Store in cookie for SSR...
setCookie('appearance', value);
updateTheme(value);
}
return {
appearance,
resolvedAppearance,
updateAppearance,
};
}
+82
View File
@@ -0,0 +1,82 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils';
export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>;
isCurrentUrl: (
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
startsWith?: boolean,
) => boolean;
isCurrentOrParentUrl: (
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) => boolean;
whenCurrentUrl: <T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: T,
ifFalse?: F,
) => T | F;
};
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn {
function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
startsWith: boolean = false,
) {
const urlToCompare = currentUrl ?? currentUrlReactive.value;
const urlString = toUrl(urlToCheck);
const comparePath = (path: string): boolean =>
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare;
if (!urlString.startsWith('http')) {
return comparePath(urlString);
}
try {
const absoluteUrl = new URL(urlString);
return comparePath(absoluteUrl.pathname);
} catch {
return false;
}
}
function isCurrentOrParentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) {
return isCurrentUrl(urlToCheck, currentUrl, true);
}
function whenCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any,
ifFalse: any = null,
) {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
}
return {
currentUrl: readonly(currentUrlReactive),
isCurrentUrl,
isCurrentOrParentUrl,
whenCurrentUrl,
};
}
+29
View File
@@ -0,0 +1,29 @@
export type UseInitialsReturn = {
getInitials: (fullName?: string) => string;
};
function getInitial(name: string): string {
return Array.from(name)[0] ?? '';
}
export function getInitials(fullName?: string): string {
if (!fullName) {
return '';
}
const names = fullName.trim().split(/\s+/u).filter(Boolean);
if (names.length === 0) {
return '';
}
if (names.length === 1) {
return getInitial(names[0]).toUpperCase();
}
return `${getInitial(names[0])}${getInitial(names[names.length - 1])}`.toUpperCase();
}
export function useInitials(): UseInitialsReturn {
return { getInitials };
}
@@ -0,0 +1,31 @@
// resources/js/composables/usePauseOffscreen.ts
import { onMounted, onUnmounted, type Ref } from 'vue';
export function usePauseOffscreen(
videoRef: Ref<HTMLVideoElement | HTMLVideoElement[] | undefined>,
) {
let observer: IntersectionObserver | null = null;
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target as HTMLVideoElement;
if (!entry.isIntersecting && !video.paused) {
video.pause();
}
}
},
{ threshold: 0.25 }, // pause once less than 25% of the video is visible
);
const els = Array.isArray(videoRef.value)
? videoRef.value
: [videoRef.value];
els.forEach((el) => el && observer!.observe(el));
});
onUnmounted(() => {
observer?.disconnect();
});
}
@@ -0,0 +1,112 @@
import { useHttp } from '@inertiajs/vue3';
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const http = useHttp();
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = (await http.submit(qrCode())) as {
svg: string;
url: string;
};
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = (await http.submit(secretKey())) as {
secretKey: string;
};
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
const fetchRecoveryCodes = async (): Promise<void> => {
try {
clearErrors();
recoveryCodesList.value = (await http.submit(
recoveryCodes(),
)) as string[];
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
};
};
+73
View File
@@ -0,0 +1,73 @@
<!-- index.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import Feed from '@/components/dredge/Feed/Feed.vue';
import MainHeader from '@/components/dredge/MainHeader.vue';
import Sidebar from '@/components/dredge/Sidebar/Sidebar.vue';
import Stories from '@/components/dredge/Stories/Stories.vue';
const depth = ref(0);
const maxDepth = 4000;
function onScroll() {
const scrolled = window.scrollY;
depth.value = Math.min(maxDepth, Math.round(scrolled * 3.4));
}
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }));
onUnmounted(() => window.removeEventListener('scroll', onScroll));
</script>
<template>
<Main>
<div class="dredge">
<MainHeader />
<div class="dredge__body">
<div class="dredge__main">
<Stories />
<slot />
</div>
<Sidebar />
</div>
</div>
</Main>
</template>
<style scoped>
.dredge {
--abyss: #050b14;
--midwater: #0e1f2e;
--panel: #10222f;
--line: #1c3140;
--sediment: #cdbfa4;
--sediment-dim: #8f8a78;
--haze: #6e8a96;
--bioluminum: #4ce0d2;
--rust-net: #b8663f;
background: var(--abyss);
color: var(--sediment);
min-height: 100vh;
font-family: 'Inter', system-ui, sans-serif;
}
.dredge__main {
min-width: 0;
}
.dredge__body {
display: grid;
grid-template-columns: minmax(0, 640px) 320px;
gap: 2.5rem;
max-width: 1040px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
align-items: start;
}
@media (max-width: 860px) {
.dredge__body {
grid-template-columns: 1fr;
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { router } from '@inertiajs/vue3';
import { toast } from 'vue-sonner';
import type { FlashToast } from '@/types/ui';
export function initializeFlashToast(): void {
router.on('flash', (event) => {
const flash = (event as CustomEvent).detail?.flash;
const data = flash?.toast as FlashToast | undefined;
if (!data) {
return;
}
toast[data.type](data.message);
});
}
+4
View File
@@ -0,0 +1,4 @@
export function proxyImage(url: string | null | undefined): string | undefined {
if (!url) return undefined;
return `/image-proxy?url=${encodeURIComponent(url)}`;
}
+12
View File
@@ -0,0 +1,12 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { clsx } from 'clsx';
import type { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function toUrl(href: NonNullable<InertiaLinkProps['href']>) {
return typeof href === 'string' ? href : href?.url;
}
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import Feed from '@/components/dredge/Feed/Feed.vue';
import AppLayout from '@/layouts/AppLayout.vue';
defineOptions({
layout: AppLayout
})
</script>
<template>
<Feed />
</template>
+27
View File
@@ -0,0 +1,27 @@
import { createInertiaApp } from '@inertiajs/vue3';
import createServer from '@inertiajs/vue3/server';
import { renderToString } from '@vue/server-renderer';
import { createSSRApp, h, type DefineComponent } from 'vue';
import AppLayout from '@/layouts/AppLayout.vue';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer((page) =>
createInertiaApp({
page,
render: renderToString,
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) => {
const pages = import.meta.glob<DefineComponent>(
'./pages/**/*.vue',
{ eager: true },
);
let page = pages[`./pages/${name}.vue`];
page.default.layout = page.default.layout || AppLayout;
return page;
},
setup({ App, props, plugin }) {
return createSSRApp({ render: () => h(App, props) }).use(plugin);
},
}),
);
+31
View File
@@ -0,0 +1,31 @@
export type User = {
id: number;
name: string;
email: string;
avatar?: string;
email_verified_at: string | null;
two_factor_enabled?: boolean;
created_at: string;
updated_at: string;
[key: string]: unknown;
};
export type Auth = {
user: User;
};
/* @chisel-passkeys */
export type Passkey = {
id: number;
name: string;
authenticator: string | null;
created_at_diff: string;
last_used_at_diff: string | null;
};
/* @end-chisel-passkeys */
export type TwoFactorConfigContent = {
title: string;
description: string;
buttonText: string;
};
+50
View File
@@ -0,0 +1,50 @@
export interface CarouselImage {
url: string | null;
video_url: string | null;
accessibility_caption: string | null;
}
export interface Post {
id: string;
code: string | null;
username: string;
full_name: string | null;
caption: string | null;
accessibility_caption: string | null;
is_video: boolean;
is_carousel: boolean;
like_count: number | null;
comment_count: number | null;
taken_at: number | null;
image_url: string | null;
video_url: string | null;
carousel_images: CarouselImage[] | null;
scraped_at: string; // ISO datetime string
permalink: string | null; // appended accessor
taken_at_date: string | null; // appended accessor, ISO datetime string
profile_pic_url: string | null;
}
export interface StoryLink {
username: string;
profile_pic_url: string | null;
is_unread: boolean;
muted: boolean;
}
export interface StoryItem {
id: string;
code: string | null;
is_video: boolean;
image_url: string | null;
video_url: string | null;
accessibility_caption: string | null;
taken_at: number;
}
export interface StoryDetail {
username: string;
profile_pic_url: string | null;
start_index: number;
items: StoryItem[];
}
+33
View File
@@ -0,0 +1,33 @@
import type { Auth } from '@/types/auth';
// Extend ImportMeta interface for Vite...
declare module 'vite/client' {
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
[key: string]: string | boolean | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
readonly glob: <T>(pattern: string) => Record<string, () => Promise<T>>;
}
}
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
auth: Auth;
sidebarOpen: boolean;
[key: string]: unknown;
};
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$inertia: typeof Router;
$page: Page;
$headManager: ReturnType<typeof createHeadManager>;
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './auth';
export * from './navigation';
export * from './ui';
+14
View File
@@ -0,0 +1,14 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import type { LucideIcon } from '@lucide/vue';
export type BreadcrumbItem = {
title: string;
href: NonNullable<InertiaLinkProps['href']>;
};
export type NavItem = {
title: string;
href: NonNullable<InertiaLinkProps['href']>;
icon?: LucideIcon;
isActive?: boolean;
};
+9
View File
@@ -0,0 +1,9 @@
export type Appearance = 'light' | 'dark' | 'system';
export type ResolvedAppearance = 'light' | 'dark';
export type AppVariant = 'header' | 'sidebar';
export type FlashToast = {
type: 'success' | 'info' | 'warning' | 'error';
message: string;
};
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
}
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@fonts
@vite(['resources/css/app.css', 'resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
<x-inertia::head>
<title>{{ config('app.name', 'Laravel') }}</title>
</x-inertia::head>
</head>
<body class="font-sans antialiased">
<x-inertia::app />
</body>
</html>