added media, mutation events, agent instructions
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s

This commit is contained in:
Daan Meijer
2026-06-15 22:30:17 +02:00
parent 1d1ca88aea
commit a1adf1da1c
44 changed files with 2083 additions and 287 deletions
+9 -5
View File
@@ -18,11 +18,15 @@ const className = computed(() => props.class);
<SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot />
</SidebarInset>
<main
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
:class="className"
>
<main v-else class="app-content" :class="className">
<slot />
</main>
</template>
<style scoped>
@reference "../../css/app.css";
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
}
</style>
+24 -8
View File
@@ -3,14 +3,30 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script>
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
<div class="app-logo__icon-container">
<AppLogoIcon class="app-logo__icon" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>Laravel Starter Kit</span
>
<div class="app-logo__text-container">
<span class="app-logo__text">Laravel Starter Kit</span>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
}
.app-logo__icon {
@apply size-5 fill-current text-white dark:text-black;
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
}
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
}
</style>
+14 -4
View File
@@ -14,10 +14,8 @@ withDefaults(
</script>
<template>
<header
class="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div class="flex items-center gap-2">
<header class="sidebar-header">
<div class="sidebar-header__inner">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
@@ -25,3 +23,15 @@ withDefaults(
</div>
</header>
</template>
<style scoped>
@reference "../../css/app.css";
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
</style>
+29 -9
View File
@@ -12,22 +12,42 @@ const tabs = [
</script>
<template>
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
>
<div class="appearance-tabs">
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
'appearance-tabs__tab',
{ 'appearance-tabs__tab--active': appearance === value },
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
<component :is="Icon" class="appearance-tabs__icon" />
<span class="appearance-tabs__label">{{ label }}</span>
</button>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.appearance-tabs {
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
}
.appearance-tabs__tab {
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
}
.appearance-tabs__tab--active {
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
</style>
+348 -30
View File
@@ -1,59 +1,377 @@
<script setup>
<script setup lang="ts">
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { route } from 'ziggy-js';
import { onMounted } from 'vue';
import { useEcho } from '@laravel/echo-vue';
import { Paperclip } from '@lucide/vue';
const props = defineProps({
chat: Object,
});
const props = defineProps<{
chat: {
id: number;
messages: Array<{
id: number;
user: { name: string };
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
}>;
};
}>();
if (!echoIsConfigured()) {
configureEcho({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY || 'mock-key',
wsHost: import.meta.env.VITE_REVERB_HOST || 'localhost',
wsPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
wssPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
forceTLS: false,
enabledTransports: ['ws', 'wss'],
});
}
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({
content: '',
media: [] as File[],
});
const echo = useEcho();
onMounted(() => {
echo.private(`chats.${props.chat.id}`)
.listen('MessageSent', (e) => {
props.chat.messages.push(e.message);
});
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message);
});
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeFile(index: number) {
form.media.splice(index, 1);
}
function submit() {
form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => form.reset(),
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
});
}
// Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null);
const activeLightboxType = ref<'image' | 'video' | null>(null);
function openLightbox(url: string, mimeType: string) {
activeLightboxUrl.value = url;
activeLightboxType.value = mimeType.startsWith('image/')
? 'image'
: 'video';
}
function closeLightbox() {
activeLightboxUrl.value = null;
activeLightboxType.value = null;
}
</script>
<template>
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Chat</h4>
<div class="mt-4 space-y-4">
<div v-for="message in chat.messages" :key="message.id" class="p-4 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="flex justify-between">
<span class="font-semibold">{{ message.user.name }}</span>
<span class="text-xs text-gray-500">{{ new Date(message.created_at).toLocaleString() }}</span>
<div class="c-chat">
<h4 class="c-chat__title">Chat</h4>
<div class="c-chat__list">
<div
v-for="message in chat.messages"
:key="message.id"
class="c-chat__message"
>
<div class="c-chat__message-header">
<span class="c-chat__message-author">{{
message.user.name
}}</span>
<span class="c-chat__message-time">{{
new Date(message.created_at).toLocaleString()
}}</span>
</div>
<p class="c-chat__message-text">
{{ message.content }}
</p>
<!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div
v-for="item in message.media"
:key="item.id"
class="c-chat__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
:alt="item.file_name"
class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
>
<video
:src="item.url"
class="c-chat__video"
></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ message.content }}</p>
</div>
<div v-if="chat.messages.length === 0" class="text-gray-500">
<div v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet.
</div>
</div>
<form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Message</label>
<textarea v-model="form.content" id="content" rows="3" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea>
<div v-if="form.errors.content" class="text-sm text-red-600">{{ form.errors.content }}</div>
<form @submit.prevent="submit" class="c-chat__form">
<div class="c-chat__form-group">
<label for="content" class="c-chat__label">Message</label>
<textarea
v-model="form.content"
id="content"
rows="3"
class="c-chat__textarea"
placeholder="Type a message..."
></textarea>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
</div>
<!-- Attachment Button & Hidden Input -->
<div class="c-chat__attachment-container">
<button
type="button"
@click="fileInput?.click()"
class="c-chat__attach-btn"
>
<Paperclip class="c-chat__attach-icon" />
Attach Photos/Videos
</button>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- Previews List -->
<div v-if="form.media.length > 0" class="c-chat__preview-list">
<div
v-for="(file, index) in form.media"
:key="index"
class="c-chat__preview-item"
>
<span class="c-chat__preview-name">{{
file.name
}}</span>
<button
type="button"
@click="removeFile(index)"
class="c-chat__preview-remove"
>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
<div class="c-chat__submit-box">
<button
type="submit"
:disabled="form.processing"
class="c-chat__button"
>
Send
</button>
</div>
</form>
<!-- Gorgeous Dark Lightbox Modal -->
<div
v-if="activeLightboxUrl"
class="c-chat__lightbox"
@click="closeLightbox"
>
<button @click="closeLightbox" class="c-chat__lightbox-close">
</button>
<div class="c-chat__lightbox-content" @click.stop>
<img
v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl"
class="c-chat__lightbox-image"
/>
<video
v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl"
controls
autoplay
class="c-chat__lightbox-video"
></video>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply mt-8;
}
.c-chat__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-chat__list {
@apply mt-4 space-y-4;
}
.c-chat__message {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-chat__message-header {
@apply flex justify-between;
}
.c-chat__message-author {
@apply font-semibold;
}
.c-chat__message-time {
@apply text-xs text-gray-500;
}
.c-chat__message-text {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-chat__empty {
@apply text-gray-500;
}
.c-chat__form {
@apply mt-6 space-y-6;
}
.c-chat__form-group {
@apply space-y-2;
}
.c-chat__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-chat__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-chat__error {
@apply text-sm text-red-600;
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
}
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs text-neutral-500 transition-colors hover:text-foreground;
}
.c-chat__attach-icon {
@apply size-3.5;
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate;
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-chat__submit-box {
@apply flex items-center gap-4;
}
.c-chat__button {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
/* Lightbox Styling */
.c-chat__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-chat__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-chat__lightbox-content {
@apply max-h-full max-w-full;
}
.c-chat__lightbox-image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-chat__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style>
+27 -9
View File
@@ -11,18 +11,36 @@ withDefaults(defineProps<Props>(), {
</script>
<template>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
<h2
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
"
>
<header :class="['c-heading', { 'c-heading--small': variant === 'small' }]">
<h2 class="c-heading__title">
{{ title }}
</h2>
<p v-if="description" class="text-sm text-muted-foreground">
<p v-if="description" class="c-heading__description">
{{ description }}
</p>
</header>
</template>
<style scoped>
@reference "../../css/app.css";
.c-heading {
@apply mb-8 space-y-0.5;
}
.c-heading--small {
@apply mb-0 space-y-0;
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
}
.c-heading--small .c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
.c-heading__description {
@apply text-sm text-muted-foreground;
}
</style>
+10 -2
View File
@@ -5,9 +5,17 @@ defineProps<{
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
<div v-show="message" class="c-input-error">
<p class="c-input-error__message">
{{ message }}
</p>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.c-input-error__message {
@apply text-sm text-red-600 dark:text-red-500;
}
</style>
+25 -9
View File
@@ -21,26 +21,42 @@ defineExpose({
</script>
<template>
<div class="relative">
<div class="password-input">
<Input
ref="inputRef"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
:class="cn('password-input__field', props.class)"
v-bind="$attrs"
/>
<button
type="button"
@click="showPassword = !showPassword"
:class="
cn(
'absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none',
)
"
class="password-input__toggle"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
<EyeOff v-if="showPassword" class="password-input__icon" />
<Eye v-else class="password-input__icon" />
</button>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.password-input {
@apply relative;
}
:deep(.password-input__field) {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none;
}
.password-input__icon {
@apply size-4;
}
</style>
+9 -1
View File
@@ -18,8 +18,16 @@ defineProps<Props>();
:tabindex="tabindex"
:method="method"
:as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
class="c-text-link"
>
<slot />
</Link>
</template>
<style scoped>
@reference "../../css/app.css";
.c-text-link {
@apply text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500;
}
</style>
+24 -6
View File
@@ -22,17 +22,35 @@ const showAvatar = computed(
</script>
<template>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
<Avatar class="user-info__avatar">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{
user.email
}}</span>
<div class="user-info__details">
<span class="user-info__name">{{ user.name }}</span>
<span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.user-info__avatar {
@apply h-8 w-8 overflow-hidden rounded-lg;
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
}
.user-info__name {
@apply truncate font-medium;
}
.user-info__email {
@apply truncate text-xs text-muted-foreground;
}
</style>