Compare commits

...

3 Commits

Author SHA1 Message Date
Daan Meijer
4e9e6dce2a front-end restructuring, chat shows message ownership
Some checks failed
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-16 14:13:08 +02:00
Daan Meijer
d44bcf6fda formatting, browser tests 2026-06-16 10:53:44 +02:00
Daan Meijer
9a9a901d46 tests 2026-06-16 10:30:50 +02:00
42 changed files with 980 additions and 511 deletions

View File

@ -2,6 +2,8 @@
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './components.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';

View File

@ -0,0 +1,16 @@
/* ==========================================================================
BEM UI Components Index Manifest (Tailwind CSS v4)
========================================================================== */
@import './components/heading.css';
@import './components/input-error.css';
@import './components/text-link.css';
@import './components/app-logo.css';
@import './components/user-info.css';
@import './components/sidebar-header.css';
@import './components/app-content.css';
@import './components/appearance-tabs.css';
@import './components/password-input.css';
@import './components/auth-layout.css';
@import './components/chat.css';
@import './components/lightbox.css';

View File

@ -0,0 +1,5 @@
/* 7. AppContent Layout Component */
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4;
border-radius: var(--radius);
}

View File

@ -0,0 +1,20 @@
/* 4. AppLogo Component */
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
color: var(--sidebar-primary-foreground);
.app-logo__icon {
@apply size-5 fill-current;
}
}
.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;
color: var(--foreground);
}
}

View File

@ -0,0 +1,35 @@
/* 8. AppearanceTabs Component */
.appearance-tabs {
@apply inline-flex gap-1 p-1;
border-radius: var(--radius);
background-color: var(--muted);
.appearance-tabs__tab {
@apply flex cursor-pointer items-center px-3.5 py-1.5 transition-colors;
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
background-color: rgba(0, 0, 0, 0.05);
}
&.appearance-tabs__tab--active {
background-color: var(--card);
color: var(--card-foreground);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
background-color: var(--card);
}
}
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
}

View File

@ -0,0 +1,46 @@
/* 10. AuthSimpleLayout Component */
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10;
background-color: var(--background);
.auth-layout__container {
@apply w-full max-w-sm;
.auth-layout__inner {
@apply flex flex-col gap-8;
.auth-layout__header {
@apply flex flex-col items-center gap-4;
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
.auth-layout__logo {
@apply size-9 fill-current;
color: var(--sidebar-primary-foreground);
}
}
}
.auth-layout__title-box {
@apply space-y-2 text-center;
.auth-layout__title {
@apply text-xl font-medium;
color: var(--foreground);
}
.auth-layout__description {
@apply text-center text-sm;
color: var(--muted-foreground);
}
}
}
}
}
}

View File

@ -0,0 +1,210 @@
/* 11. Chat Component */
.c-chat {
@apply mt-8;
.c-chat__title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-chat__list {
@apply mt-4 flex flex-col gap-3;
.c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border);
&.c-chat__message--system {
@apply w-full self-center border-0 bg-transparent p-0 shadow-none;
}
&.c-chat__message--own {
@apply self-end rounded-br-none text-right;
max-width: 80%;
background-color: var(--primary);
border-color: var(--primary);
.c-chat__message-header {
@apply flex-row-reverse;
}
.c-chat__message-author {
color: var(--primary-foreground);
}
.c-chat__message-time {
color: var(--primary-foreground);
opacity: 0.7;
}
.c-chat__message-text {
color: var(--primary-foreground);
}
}
&.c-chat__message--other {
@apply self-start rounded-bl-none;
max-width: 80%;
background-color: var(--muted);
border-color: var(--border);
.c-chat__message-author {
color: var(--foreground);
}
.c-chat__message-time {
color: var(--muted-foreground);
}
.c-chat__message-text {
color: var(--foreground);
}
}
.c-chat__system-inner {
@apply flex items-center gap-2 px-3 py-1.5 text-xs;
background-color: var(--muted);
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
.c-chat__system-icon {
@apply size-3.5 shrink-0;
color: var(--muted-foreground);
}
.c-chat__system-text {
@apply flex-1 font-medium;
}
.c-chat__system-time {
@apply shrink-0 text-[10px];
color: var(--muted-foreground);
}
}
.c-chat__message-header {
@apply flex justify-between;
.c-chat__message-author {
@apply font-semibold;
color: var(--foreground);
}
.c-chat__message-time {
@apply text-xs;
color: var(--muted-foreground);
}
}
.c-chat__message-text {
@apply mt-2 text-sm;
color: var(--foreground);
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden bg-black;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
.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 {
color: var(--muted-foreground);
}
.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;
color: var(--foreground);
}
.c-chat__textarea {
@apply mt-1 block w-full shadow-sm;
border-radius: var(--radius);
border: 1px solid var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-chat__error {
@apply text-sm;
color: var(--destructive);
}
.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 transition-colors;
color: var(--muted-foreground);
&:hover {
color: var(--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 p-1.5 pr-8 text-xs;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
background-color: var(--muted);
.c-chat__preview-name {
@apply max-w-[150px] truncate;
color: var(--foreground);
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px];
color: var(--muted-foreground);
&:hover {
color: var(--destructive);
}
}
}
}
}
.c-chat__submit-box {
@apply flex items-center gap-4;
.c-chat__button {
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
border-radius: var(--radius);
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
}
}
}

View File

@ -0,0 +1,22 @@
/* 1. Heading Component */
.c-heading {
@apply mb-8 space-y-0.5;
&.c-heading--small {
@apply mb-0 space-y-0;
.c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
color: var(--foreground);
}
.c-heading__description {
@apply text-sm;
color: var(--muted-foreground);
}
}

View File

@ -0,0 +1,5 @@
/* 2. InputError Component */
.c-input-error__message {
@apply text-sm;
color: var(--destructive);
}

View File

@ -0,0 +1,25 @@
/* 12. Reusable Lightbox Component */
.c-lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
background-color: rgba(0, 0, 0, 0.95);
.c-lightbox__close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200;
&:hover {
color: var(--destructive);
}
}
.c-lightbox__content {
@apply max-h-full max-w-full;
.c-lightbox__image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-lightbox__video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
}
}

View File

@ -0,0 +1,21 @@
/* 9. PasswordInput Component */
.password-input {
@apply relative;
.password-input__field {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none;
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
}
}
.password-input__icon {
@apply size-4;
}
}

View File

@ -0,0 +1,14 @@
/* 6. AppSidebarHeader Component */
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear md:px-4;
border-bottom: 1px solid var(--sidebar-border);
background-color: var(--sidebar-background);
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
.group-has-data-[collapsible='icon']/sidebar-wrapper:h-12 .sidebar-header {
@apply h-12;
}

View File

@ -0,0 +1,10 @@
/* 3. TextLink Component */
.c-text-link {
@apply underline underline-offset-4 transition-colors duration-300 ease-out;
color: var(--foreground);
text-decoration-color: var(--border);
&:hover {
text-decoration-color: var(--foreground);
}
}

View File

@ -0,0 +1,19 @@
/* 5. UserInfo Component */
.user-info__avatar {
@apply h-8 w-8 overflow-hidden;
border-radius: var(--radius);
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
.user-info__name {
@apply truncate font-medium;
color: var(--foreground);
}
.user-info__email {
@apply truncate text-xs;
color: var(--muted-foreground);
}
}

View File

@ -19,7 +19,7 @@ configureEcho({
forceTLS: false, forceTLS: false,
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
if(window){ if (typeof window !== 'undefined') {
(window as any).echoConfigured = true; (window as any).echoConfigured = true;
} }

View File

@ -42,17 +42,10 @@ function submit() {
<template> <template>
<div class="c-add-mutation-form"> <div class="c-add-mutation-form">
<h4 class="c-add-mutation-form__title"> <h4 class="c-add-mutation-form__title">Add Mutation</h4>
Add Mutation <form @submit.prevent="submit" class="c-add-mutation-form__form">
</h4>
<form
@submit.prevent="submit"
class="c-add-mutation-form__form"
>
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label for="amount" class="c-add-mutation-form__label"
for="amount"
class="c-add-mutation-form__label"
>Amount</label >Amount</label
> >
<input <input
@ -61,15 +54,16 @@ function submit() {
type="number" type="number"
class="c-add-mutation-form__input" class="c-add-mutation-form__input"
/> />
<div v-if="form.errors.amount" class="c-add-mutation-form__error"> <div
v-if="form.errors.amount"
class="c-add-mutation-form__error"
>
{{ form.errors.amount }} {{ form.errors.amount }}
</div> </div>
</div> </div>
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label for="description" class="c-add-mutation-form__label"
for="description"
class="c-add-mutation-form__label"
>Description</label >Description</label
> >
<textarea <textarea
@ -88,8 +82,7 @@ function submit() {
<!-- Media Uploads for Mutations --> <!-- Media Uploads for Mutations -->
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label class="c-add-mutation-form__label"
class="c-add-mutation-form__label"
>Attach Proof Media (Photos/Videos)</label >Attach Proof Media (Photos/Videos)</label
> >
<input <input

View File

@ -22,11 +22,3 @@ const className = computed(() => props.class);
<slot /> <slot />
</main> </main>
</template> </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>

View File

@ -33,6 +33,7 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { route } from 'ziggy-js';
import { getInitials } from '@/composables/useInitials'; import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
@ -239,7 +240,7 @@ const rightNavItems: NavItem[] = [
</div> </div>
<div <div
v-if="page.props.unreadNotificationsCount > 0" v-if="(page.props as any).unreadNotificationsCount > 0"
class="relative" class="relative"
> >
<Link <Link
@ -260,7 +261,9 @@ const rightNavItems: NavItem[] = [
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/> />
</svg> </svg>
<span class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"> <span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
>
{{ page.props.unreadNotificationsCount }} {{ page.props.unreadNotificationsCount }}
</span> </span>
</Link> </Link>

View File

@ -10,23 +10,3 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<span class="app-logo__text">Laravel Starter Kit</span> <span class="app-logo__text">Laravel Starter Kit</span>
</div> </div>
</template> </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>

View File

@ -23,15 +23,3 @@ withDefaults(
</div> </div>
</header> </header>
</template> </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>

View File

@ -27,27 +27,3 @@ const tabs = [
</button> </button>
</div> </div>
</template> </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>

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { ref, computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { Paperclip } from '@lucide/vue';
import { ref } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue';
const props = defineProps<{ const props = defineProps<{
chat: { chat: {
id: number; id: number;
messages: Array<{ messages: Array<{
id: number; id: number;
user: { name: string }; user: { id: number; name: string };
content: string; content: string;
created_at: string; created_at: string;
media?: Array<{ media?: Array<{
@ -52,7 +52,6 @@ useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
if (files) { if (files) {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
form.media.push(files[i]); form.media.push(files[i]);
@ -64,11 +63,16 @@ function removeFile(index: number) {
form.media.splice(index, 1); form.media.splice(index, 1);
} }
const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number): boolean {
return currentUser.value && currentUser.value.id === messageUserId;
}
function submit() { function submit() {
form.post(route('chats.messages.store', props.chat.id), { form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = ''; fileInput.value.value = '';
} }
@ -100,50 +104,81 @@ function closeLightbox() {
<div <div
v-for="message in chat.messages" v-for="message in chat.messages"
:key="message.id" :key="message.id"
class="c-chat__message" :class="[
'c-chat__message',
{
'c-chat__message--system':
message.content.startsWith('System:'),
'c-chat__message--own': isOwnMessage(message.user.id),
'c-chat__message--other': !isOwnMessage(
message.user.id,
),
},
]"
> >
<div class="c-chat__message-header"> <!-- Standard User Chat Message -->
<span class="c-chat__message-author">{{ <template v-if="!message.content.startsWith('System:')">
message.user.name <div class="c-chat__message-header">
}}</span> <span class="c-chat__message-author">{{
<span class="c-chat__message-time">{{ message.user.name
new Date(message.created_at).toLocaleString() }}</span>
}}</span> <span class="c-chat__message-time">{{
</div> new Date(message.created_at).toLocaleString()
<p class="c-chat__message-text"> }}</span>
{{ message.content }} </div>
</p> <p class="c-chat__message-text">
{{ message.content }}
</p>
<!-- Attached Media Display --> <!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div <div
v-for="item in message.media" v-if="message.media && message.media.length > 0"
:key="item.id" class="c-chat__message-media"
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 <div
v-else-if="item.mime_type.startsWith('video/')" v-for="item in message.media"
class="relative cursor-pointer transition-opacity hover:opacity-90" :key="item.id"
@click="openLightbox(item.url, item.mime_type)" class="c-chat__media-item"
> >
<video <img
v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="c-chat__video" :alt="item.file_name"
></video> class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
<div class="c-chat__play-overlay"></div> @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>
</div> </div>
</div> </template>
<!-- Subtle Activity Log System Message -->
<template v-else>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span class="c-chat__system-text">
{{ message.content.replace(/^System:\s*/, '') }}
</span>
<span class="c-chat__system-time">
{{
new Date(message.created_at).toLocaleTimeString(
[],
{ hour: '2-digit', minute: '2-digit' },
)
}}
</span>
</div>
</template>
</div> </div>
<div v-if="chat.messages.length === 0" class="c-chat__empty"> <div v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
@ -215,165 +250,22 @@ function closeLightbox() {
</form> </form>
<!-- Gorgeous Dark Lightbox Modal --> <!-- Gorgeous Dark Lightbox Modal -->
<div <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
v-if="activeLightboxUrl" <button @click="closeLightbox" class="c-lightbox__close"></button>
class="c-chat__lightbox" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button @click="closeLightbox" class="c-chat__lightbox-close">
</button>
<div class="c-chat__lightbox-content" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-chat__lightbox-image" class="c-lightbox__image"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-chat__lightbox-video" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
</div> </div>
</template> </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>

View File

@ -40,11 +40,12 @@ function submit() {
<div class="c-create-ledger-form__body"> <div class="c-create-ledger-form__body">
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3> <h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
<form @submit.prevent="submit" class="c-create-ledger-form__form"> <form
@submit.prevent="submit"
class="c-create-ledger-form__form"
>
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label for="name" class="c-create-ledger-form__label"
for="name"
class="c-create-ledger-form__label"
>Name</label >Name</label
> >
<input <input
@ -62,9 +63,7 @@ function submit() {
</div> </div>
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label for="rules" class="c-create-ledger-form__label"
for="rules"
class="c-create-ledger-form__label"
>Rules</label >Rules</label
> >
<textarea <textarea
@ -92,9 +91,15 @@ function submit() {
id="alignment" id="alignment"
class="c-create-ledger-form__select" class="c-create-ledger-form__select"
> >
<option value="positive">Positive (Higher Score is Better)</option> <option value="positive">
<option value="neutral">Neutral (Frictionless / Standard)</option> Positive (Higher Score is Better)
<option value="negative">Negative (Lower Score is Better / Demerits)</option> </option>
<option value="neutral">
Neutral (Frictionless / Standard)
</option>
<option value="negative">
Negative (Lower Score is Better / Demerits)
</option>
</select> </select>
<div <div
v-if="form.errors.alignment" v-if="form.errors.alignment"
@ -106,8 +111,7 @@ function submit() {
<!-- Media Uploads for Ledgers --> <!-- Media Uploads for Ledgers -->
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label class="c-create-ledger-form__label"
class="c-create-ledger-form__label"
>Attach Cover/Rules Media</label >Attach Cover/Rules Media</label
> >
<input <input
@ -126,9 +130,10 @@ function submit() {
:key="index" :key="index"
class="c-create-ledger-form__media-preview-item" class="c-create-ledger-form__media-preview-item"
> >
<span class="c-create-ledger-form__media-preview-name">{{ <span
file.name class="c-create-ledger-form__media-preview-name"
}}</span> >{{ file.name }}</span
>
<button <button
type="button" type="button"
@click="removeLedgerFile(index)" @click="removeLedgerFile(index)"
@ -163,11 +168,15 @@ function submit() {
} }
.c-create-ledger-form__card { .c-create-ledger-form__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-create-ledger-form__body { .c-create-ledger-form__body {
@apply p-6 text-gray-900 dark:text-gray-100; @apply p-6;
color: var(--foreground);
} }
.c-create-ledger-form__title { .c-create-ledger-form__title {
@ -183,19 +192,29 @@ function submit() {
} }
.c-create-ledger-form__label { .c-create-ledger-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300; @apply block text-sm font-medium;
color: var(--foreground);
} }
.c-create-ledger-form__input { .c-create-ledger-form__input {
@apply mt-1 block w-full rounded-md border 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; @apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__textarea { .c-create-ledger-form__textarea {
@apply mt-1 block w-full rounded-md border 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; @apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__select { .c-create-ledger-form__select {
@apply mt-1 block w-full rounded-md border border-gray-300 bg-white p-2 text-sm 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; @apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__file-input { .c-create-ledger-form__file-input {

View File

@ -20,27 +20,3 @@ withDefaults(defineProps<Props>(), {
</p> </p>
</header> </header>
</template> </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>

View File

@ -11,11 +11,3 @@ defineProps<{
</p> </p>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-input-error__message {
@apply text-sm text-red-600 dark:text-red-500;
}
</style>

View File

@ -17,9 +17,7 @@ defineProps<{
<template> <template>
<div class="c-ledger-list"> <div class="c-ledger-list">
<div class="c-ledger-list__header"> <div class="c-ledger-list__header">
<h4 class="c-ledger-list__title"> <h4 class="c-ledger-list__title">Ledgers</h4>
Ledgers
</h4>
</div> </div>
<ul class="c-ledger-list__grid"> <ul class="c-ledger-list__grid">
<li <li
@ -107,7 +105,8 @@ defineProps<{
} }
.c-ledger-list__title { .c-ledger-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100; @apply text-lg font-medium;
color: var(--foreground);
} }
.c-ledger-list__grid { .c-ledger-list__grid {
@ -115,15 +114,20 @@ defineProps<{
} }
.c-ledger-list__item { .c-ledger-list__item {
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700; @apply p-6;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-ledger-list__item-name { .c-ledger-list__item-name {
@apply text-lg font-semibold; @apply text-lg font-semibold;
color: var(--foreground);
} }
.c-ledger-list__item-score { .c-ledger-list__item-score {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400; @apply mt-2 text-sm;
color: var(--muted-foreground);
} }
.c-ledger-list__alignment-wrapper { .c-ledger-list__alignment-wrapper {
@ -131,19 +135,19 @@ defineProps<{
} }
.c-ledger-list__alignment-badge { .c-ledger-list__alignment-badge {
@apply text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded; @apply rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase;
} }
.c-ledger-list__alignment-badge--positive { .c-ledger-list__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20; @apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
} }
.c-ledger-list__alignment-badge--negative { .c-ledger-list__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20; @apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
} }
.c-ledger-list__alignment-badge--neutral { .c-ledger-list__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20; @apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
} }
.c-ledger-list__media-list { .c-ledger-list__media-list {

View File

@ -50,12 +50,16 @@ function getAmountClass(amount: number): string {
const alignment = props.ledgerAlignment || 'neutral'; const alignment = props.ledgerAlignment || 'neutral';
if (alignment === 'positive') { if (alignment === 'positive') {
return amount > 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative'; return amount > 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
} }
if (alignment === 'negative') { if (alignment === 'negative') {
// Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable // Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable
return amount < 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative'; return amount < 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
} }
// Neutral alignment // Neutral alignment
@ -65,9 +69,7 @@ function getAmountClass(amount: number): string {
<template> <template>
<div class="c-mutation-list"> <div class="c-mutation-list">
<h4 class="c-mutation-list__title"> <h4 class="c-mutation-list__title">Mutations</h4>
Mutations
</h4>
<ul class="c-mutation-list__list"> <ul class="c-mutation-list__list">
<li <li
v-for="mutation in mutations" v-for="mutation in mutations"

View File

@ -9,9 +9,7 @@ defineProps<{
<template> <template>
<div class="c-participants-list"> <div class="c-participants-list">
<h4 class="c-participants-list__title"> <h4 class="c-participants-list__title">Participants</h4>
Participants
</h4>
<ul class="c-participants-list__grid"> <ul class="c-participants-list__grid">
<li <li
v-for="participant in participants" v-for="participant in participants"
@ -32,7 +30,8 @@ defineProps<{
} }
.c-participants-list__title { .c-participants-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100; @apply text-lg font-medium;
color: var(--foreground);
} }
.c-participants-list__grid { .c-participants-list__grid {
@ -40,6 +39,10 @@ defineProps<{
} }
.c-participants-list__item { .c-participants-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden p-4;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
} }
</style> </style>

View File

@ -40,23 +40,3 @@ defineExpose({
</button> </button>
</div> </div>
</template> </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>

View File

@ -23,11 +23,3 @@ defineProps<Props>();
<slot /> <slot />
</Link> </Link>
</template> </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>

View File

@ -34,23 +34,3 @@ const showAvatar = computed(
<span v-if="showEmail" class="user-info__email">{{ user.email }}</span> <span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
</div> </div>
</template> </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>

View File

@ -32,47 +32,3 @@ defineProps<{
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10;
}
.auth-layout__container {
@apply w-full max-w-sm;
}
.auth-layout__inner {
@apply flex flex-col gap-8;
}
.auth-layout__header {
@apply flex flex-col items-center gap-4;
}
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
}
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center rounded-md;
}
.auth-layout__logo {
@apply size-9 fill-current text-[var(--foreground)] dark:text-white;
}
.auth-layout__title-box {
@apply space-y-2 text-center;
}
.auth-layout__title {
@apply text-xl font-medium;
}
.auth-layout__description {
@apply text-center text-sm text-muted-foreground;
}
</style>

View File

@ -62,7 +62,7 @@ function formatTime(isoString: string): string {
'c-dashboard__badge-type', 'c-dashboard__badge-type',
entity.type === 'Dynamic' entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic' ? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger' : 'c-dashboard__badge-type--ledger',
]" ]"
> >
{{ entity.type }} {{ entity.type }}
@ -71,7 +71,10 @@ function formatTime(isoString: string): string {
{{ entity.unread_count }} New {{ entity.unread_count }} New
</span> </span>
</div> </div>
<Link :href="entity.url" class="c-dashboard__entity-link"> <Link
:href="entity.url"
class="c-dashboard__entity-link"
>
<h3 class="c-dashboard__entity-title"> <h3 class="c-dashboard__entity-title">
{{ entity.name }} {{ entity.name }}
</h3> </h3>
@ -103,7 +106,9 @@ function formatTime(isoString: string): string {
v-if="entity.context_activities.length > 0" v-if="entity.context_activities.length > 0"
class="c-dashboard__divider" class="c-dashboard__divider"
> >
<span class="c-dashboard__divider-text">New Activity Below</span> <span class="c-dashboard__divider-text"
>New Activity Below</span
>
</div> </div>
<!-- New / Unread Activities --> <!-- New / Unread Activities -->
@ -131,9 +136,7 @@ function formatTime(isoString: string): string {
<!-- Empty Caught-Up State --> <!-- Empty Caught-Up State -->
<div v-else class="c-dashboard__empty-state"> <div v-else class="c-dashboard__empty-state">
<div class="c-dashboard__empty-icon"> <div class="c-dashboard__empty-icon">🔒</div>
🔒
</div>
<p class="c-dashboard__empty-text"> <p class="c-dashboard__empty-text">
All chambers are currently quiet. All chambers are currently quiet.
</p> </p>
@ -149,7 +152,7 @@ function formatTime(isoString: string): string {
@reference "../../css/app.css"; @reference "../../css/app.css";
.c-dashboard { .c-dashboard {
@apply py-8 px-4 sm:px-6 lg:px-8; @apply px-4 py-8 sm:px-6 lg:px-8;
} }
.c-dashboard__container { .c-dashboard__container {
@ -157,7 +160,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__title { .c-dashboard__title {
@apply text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 mb-6; @apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
} }
.c-dashboard__grid { .c-dashboard__grid {
@ -169,15 +172,15 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__card-header { .c-dashboard__card-header {
@apply p-6 border-b border-neutral-100 dark:border-neutral-800/30; @apply border-b border-neutral-100 p-6 dark:border-neutral-800/30;
} }
.c-dashboard__entity-meta { .c-dashboard__entity-meta {
@apply flex items-center gap-2 mb-2; @apply mb-2 flex items-center gap-2;
} }
.c-dashboard__badge-type { .c-dashboard__badge-type {
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded; @apply rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase;
} }
.c-dashboard__badge-type--dynamic { .c-dashboard__badge-type--dynamic {
@ -189,7 +192,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__unread-count { .c-dashboard__unread-count {
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-400; @apply rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-bold tracking-wider text-red-800 uppercase dark:bg-red-950/20 dark:text-red-400;
} }
.c-dashboard__entity-link { .c-dashboard__entity-link {
@ -201,23 +204,23 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__activity-list { .c-dashboard__activity-list {
@apply flex-1 p-6 space-y-4; @apply flex-1 space-y-4 p-6;
} }
.c-dashboard__activity-item { .c-dashboard__activity-item {
@apply p-4 rounded-lg border; @apply rounded-lg border p-4;
} }
.c-dashboard__activity-item--read { .c-dashboard__activity-item--read {
@apply bg-neutral-50/50 border-neutral-200 opacity-60 dark:bg-neutral-950/20 dark:border-neutral-800/50; @apply border-neutral-200 bg-neutral-50/50 opacity-60 dark:border-neutral-800/50 dark:bg-neutral-950/20;
} }
.c-dashboard__activity-item--unread { .c-dashboard__activity-item--unread {
@apply bg-red-50/10 border-red-200/50 dark:bg-red-950/5 dark:border-red-900/30; @apply border-red-200/50 bg-red-50/10 dark:border-red-900/30 dark:bg-red-950/5;
} }
.c-dashboard__activity-meta { .c-dashboard__activity-meta {
@apply flex items-center gap-2 mb-1.5 text-xs; @apply mb-1.5 flex items-center gap-2 text-xs;
} }
.c-dashboard__activity-user { .c-dashboard__activity-user {
@ -229,7 +232,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__new-badge { .c-dashboard__new-badge {
@apply ml-auto text-[9px] font-bold text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-950/30 px-1 py-0.5 rounded; @apply ml-auto rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-600 dark:bg-red-950/30 dark:text-red-400;
} }
.c-dashboard__activity-desc { .c-dashboard__activity-desc {
@ -237,11 +240,11 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__divider { .c-dashboard__divider {
@apply relative flex items-center justify-center my-4; @apply relative my-4 flex items-center justify-center;
} }
.c-dashboard__divider-text { .c-dashboard__divider-text {
@apply bg-white dark:bg-neutral-900 px-3 text-[10px] font-bold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 z-10; @apply z-10 bg-white px-3 text-[10px] font-bold tracking-widest text-neutral-400 uppercase dark:bg-neutral-900 dark:text-neutral-500;
} }
.c-dashboard__divider::before { .c-dashboard__divider::before {
@ -250,11 +253,11 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__empty-state { .c-dashboard__empty-state {
@apply flex flex-col items-center justify-center p-12 text-center rounded-2xl border border-dashed border-neutral-200 dark:border-neutral-800 min-h-[300px]; @apply flex min-h-[300px] flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-200 p-12 text-center dark:border-neutral-800;
} }
.c-dashboard__empty-icon { .c-dashboard__empty-icon {
@apply text-4xl mb-4; @apply mb-4 text-4xl;
} }
.c-dashboard__empty-text { .c-dashboard__empty-text {
@ -262,6 +265,6 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__empty-subtext { .c-dashboard__empty-subtext {
@apply mt-1 text-sm text-neutral-500 dark:text-neutral-500 max-w-md; @apply mt-1 max-w-md text-sm text-neutral-500 dark:text-neutral-500;
} }
</style> </style>

View File

@ -30,13 +30,16 @@ function submit() {
<div class="c-dynamics-create__container"> <div class="c-dynamics-create__container">
<div class="c-dynamics-create__card"> <div class="c-dynamics-create__card">
<div class="c-dynamics-create__body"> <div class="c-dynamics-create__body">
<h3 class="c-dynamics-create__title">Create a New Dynamic</h3> <h3 class="c-dynamics-create__title">
Create a New Dynamic
</h3>
<form @submit.prevent="submit" class="c-dynamics-create__form"> <form
@submit.prevent="submit"
class="c-dynamics-create__form"
>
<div class="c-dynamics-create__field"> <div class="c-dynamics-create__field">
<label <label for="name" class="c-dynamics-create__label"
for="name"
class="c-dynamics-create__label"
>Name</label >Name</label
> >
<input <input
@ -54,9 +57,7 @@ function submit() {
</div> </div>
<div class="c-dynamics-create__field"> <div class="c-dynamics-create__field">
<label <label for="rules" class="c-dynamics-create__label"
for="rules"
class="c-dynamics-create__label"
>Rules</label >Rules</label
> >
<textarea <textarea

View File

@ -51,7 +51,9 @@ const breadcrumbs = [
</div> </div>
</div> </div>
<div v-else> <div v-else>
<p class="c-dynamics-index__empty">You don't have any dynamics yet.</p> <p class="c-dynamics-index__empty">
You don't have any dynamics yet.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,6 +17,7 @@ const props = defineProps<{
id: number; id: number;
name: string; name: string;
score: number; score: number;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
@ -75,11 +76,15 @@ const breadcrumbs = [
} }
.c-dynamic-show__card { .c-dynamic-show__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-dynamic-show__body { .c-dynamic-show__body {
@apply p-6 text-gray-900 dark:text-gray-100; @apply p-6;
color: var(--foreground);
} }
.c-dynamic-show__title { .c-dynamic-show__title {

View File

@ -188,7 +188,8 @@ function isOwnerUser(userId: number): boolean {
v-else-if="ledger.alignment === 'negative'" v-else-if="ledger.alignment === 'negative'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative" class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative"
> >
Negative Alignment A lower score is better (demerits / infractions). Negative Alignment A lower score is better
(demerits / infractions).
</span> </span>
<span <span
v-else v-else
@ -249,29 +250,20 @@ function isOwnerUser(userId: number): boolean {
</div> </div>
<!-- Lightbox Modal --> <!-- Lightbox Modal -->
<div <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
v-if="activeLightboxUrl" <button @click="closeLightbox" class="c-lightbox__close"></button>
class="c-ledger-show__lightbox" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button
@click="closeLightbox"
class="c-ledger-show__lightbox-close"
>
</button>
<div class="c-ledger-show__lightbox-content" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-ledger-show__lightbox-img" class="c-lightbox__image"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-ledger-show__lightbox-video" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
@ -325,19 +317,19 @@ function isOwnerUser(userId: number): boolean {
} }
.c-ledger-show__alignment-badge { .c-ledger-show__alignment-badge {
@apply text-xs font-semibold uppercase tracking-wide px-2.5 py-1 rounded; @apply rounded px-2.5 py-1 text-xs font-semibold tracking-wide uppercase;
} }
.c-ledger-show__alignment-badge--positive { .c-ledger-show__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20; @apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
} }
.c-ledger-show__alignment-badge--negative { .c-ledger-show__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20; @apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
} }
.c-ledger-show__alignment-badge--neutral { .c-ledger-show__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20; @apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
} }
.c-ledger-show__media-list { .c-ledger-show__media-list {
@ -363,24 +355,4 @@ function isOwnerUser(userId: number): boolean {
.c-ledger-show__media-video-overlay { .c-ledger-show__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white; @apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
} }
.c-ledger-show__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-ledger-show__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-ledger-show__lightbox-content {
@apply max-h-full max-w-full;
}
.c-ledger-show__lightbox-img {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-ledger-show__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style> </style>

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('user can register, log in, and log out', function () {
$this->browse(function (Browser $browser) {
// 1. Test Registration
$browser->visit('/register')
->waitForText('Create an account')
->type('name', 'New Browser User')
->type('email', 'newbrowseruser@example.com')
->type('password', 'password')
->type('password_confirmation', 'password')
->press('Create account')
->waitForLocation('/dashboard')
->assertPathIs('/dashboard')
->assertSee('New Browser User');
// 2. Test Logout
// Open the user menu (trigger button shows initials or user name)
$browser->click('button[aria-haspopup="menu"]')
->waitForText('Log out')
->clickLink('Log out')
->waitForLocation('/login') // Fortify logs out and redirects to login or home
->assertPathIs('/login');
// 3. Test Login
$browser->type('email', 'newbrowseruser@example.com')
->type('password', 'password')
->press('Log in')
->waitForLocation('/dashboard')
->assertPathIs('/dashboard')
->assertSee('New Browser User');
});
});

View File

@ -0,0 +1,83 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('access control and actions are enforced for owners and participants', function () {
// Create database state
$owner = User::factory()->create([
'name' => 'Owner Alice',
'email' => 'alice-owner@example.com',
'password' => bcrypt('password'),
]);
$participant = User::factory()->create([
'name' => 'Participant Bob',
'email' => 'bob-sub@example.com',
'password' => bcrypt('password'),
]);
$outsider = User::factory()->create([
'name' => 'Outsider Charlie',
'email' => 'charlie-outsider@example.com',
'password' => bcrypt('password'),
]);
$dynamic = Dynamic::create([
'name' => 'Private Club',
'rules' => 'Strict access control.',
]);
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::create([
'dynamic_id' => $dynamic->id,
'name' => 'Rules Compliance',
'rules' => 'Score rules.',
'score' => 100,
'alignment' => 'neutral',
]);
$this->browse(function (Browser $sessionOwner, Browser $sessionParticipant, Browser $sessionOutsider) use ($dynamic, $ledger, $owner, $participant, $outsider) {
// 1. Test Outsider trying to access dynamic they DO NOT belong to (should be forbidden / 403)
$sessionOutsider->loginAs($outsider)
->visit(route('dynamics.show', $dynamic))
->assertSee('403') // Laravel / Inertia forbidden page
->assertDontSee('Private Club');
// 2. Test Participant accessing dynamic they DO belong to (should be allowed)
$sessionParticipant->loginAs($participant)
->visit(route('dynamics.show', $dynamic))
->waitForText('Private Club')
->assertSee('Private Club')
->assertSee('Participant Bob');
// 3. Test Participant adding a mutation suggestion
$sessionParticipant->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
->waitForText('Add Mutation')
->type('amount', '20')
->type('description', 'Cleaned the main room')
->press('Add Mutation')
->waitForText('PENDING')
->assertSee('PENDING') // Mutation should show up as pending
->assertDontSee('Approve'); // Standard participant should NOT see approve button!
// 4. Test Owner logging in, seeing the pending suggestion, and approving it!
$sessionOwner->loginAs($owner)
->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
->waitForText('Cleaned the main room')
->assertSee('PENDING')
->assertSee('Approve') // Owner should see the Approve button!
->press('Approve')
->waitForText('Score: 120') // Score updated from 100 to 120 after approval!
->assertDontSee('PENDING'); // No longer pending!
});
});

View File

@ -0,0 +1,67 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('multiple sessions can communicate in real time through websockets', function () {
// 1. Create realistic database state
$owner = User::factory()->create([
'name' => 'TU Test User',
'email' => 'test-owner@example.com',
'password' => bcrypt('password'),
]);
$participant = User::factory()->create([
'name' => 'Submissive Bob',
'email' => 'test-sub@example.com',
'password' => bcrypt('password'),
]);
$dynamic = Dynamic::create([
'name' => 'The Test Sanctuary',
'rules' => 'Rules for realtime testing.',
]);
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
// 2. Spawn two separate browser sessions/browsers in parallel
$this->browse(function (Browser $sessionA, Browser $sessionB) use ($dynamic, $owner, $participant) {
// --- SESSION A: Owner ---
$sessionA->loginAs($owner)
->visit(route('dynamics.show', $dynamic))
->waitForText('The Test Sanctuary')
->assertSee('TU Test User'); // Verify loaded in as Owner
// --- SESSION B: Participant ---
$sessionB->loginAs($participant)
->visit(route('dynamics.show', $dynamic))
->waitForText('The Test Sanctuary')
->assertSee('Submissive Bob'); // Verify loaded in as Submissive/Participant
// --- REAL-TIME COMMUNICATING ---
// Owner types and sends a message in chat
$sessionA->type('#content', 'Hello Submissive Bob, did you complete your daily chores?')
->click('.c-chat__button')
->waitForText('Hello Submissive Bob');
// Since websockets broadcast in real-time, Session B receives it without reloading
$sessionB->waitForText('Hello Submissive Bob', 5)
->assertSee('Hello Submissive Bob, did you complete your daily chores?');
// Participant replies in real-time
$sessionB->type('#content', 'Yes Master, everything is complete and logged in the ledger!')
->click('.c-chat__button')
->waitForText('Yes Master, everything is complete');
// Session A receives the reply in real-time without reloading
$sessionA->waitForText('Yes Master, everything is complete', 5)
->assertSee('Yes Master, everything is complete and logged in the ledger!');
});
});

44
tests/DuskTestCase.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace Tests;
use Laravel\Dusk\TestCase as BaseTestCase;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
abstract class DuskTestCase extends BaseTestCase
{
/**
* Prepare for Dusk test execution.
*
* @beforeClass
*/
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
'--disable-gpu',
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
])->unless(static::runningInSail(), function (collect $arguments) {
return $arguments->push('--disable-smooth-scrolling');
})->all());
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options)
);
}
}

View File

@ -0,0 +1,75 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Models\Media;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('media can be attached to mutations, ledgers, and messages', function () {
Storage::fake('public');
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($user);
// 1. Test attaching media to a mutation
$file1 = UploadedFile::fake()->image('proof1.jpg');
$file2 = UploadedFile::fake()->image('proof2.png');
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => 50,
'description' => 'Completed deep clean',
'media' => [$file1, $file2],
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
$mutation = Mutation::firstWhere('description', 'Completed deep clean');
expect($mutation)->not->toBeNull();
expect($mutation->media)->toHaveCount(2);
expect($mutation->media->first()->file_name)->toBe('proof1.jpg');
expect($mutation->media->last()->file_name)->toBe('proof2.png');
Storage::disk('public')->assertExists($mutation->media->first()->file_path);
Storage::disk('public')->assertExists($mutation->media->last()->file_path);
// 2. Test attaching media to a ledger
$file3 = UploadedFile::fake()->image('rules.jpg');
$response = $this->post(route('dynamics.ledgers.store', $dynamic), [
'name' => 'Worship Ledger',
'rules' => 'Specific rules',
'alignment' => 'neutral',
'media' => [$file3],
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
$newLedger = Ledger::firstWhere('name', 'Worship Ledger');
expect($newLedger)->not->toBeNull();
expect($newLedger->media)->toHaveCount(1);
expect($newLedger->media->first()->file_name)->toBe('rules.jpg');
// 3. Test attaching media to a chat message
$chat = $dynamic->chat;
$file4 = UploadedFile::fake()->image('chat_img.jpg');
$response = $this->post(route('chats.messages.store', $chat), [
'content' => 'Check this out!',
'media' => [$file4],
]);
$response->assertRedirect();
$message = Message::firstWhere('content', 'Check this out!');
expect($message)->not->toBeNull();
expect($message->media)->toHaveCount(1);
expect($message->media->first()->file_name)->toBe('chat_img.jpg');
});