Add light/dark mode support with CSS variables

This commit is contained in:
Jennie Robinson Faber 2025-10-06 19:54:20 +01:00
parent 970b185151
commit fb02688166
25 changed files with 1293 additions and 1177 deletions

View file

@ -7,7 +7,7 @@
>
<!-- Left: Copyright and minimal info -->
<div>
<p class="text-stone-500 text-xs mb-2">
<p class="text-ghost-500 text-xs mb-2">
© {{ currentYear }} Ghost Guild
</p>
</div>
@ -16,7 +16,7 @@
<div class="flex flex-wrap gap-6 text-xs">
<a
href="mailto:hello@ghostguild.org"
class="text-stone-500 hover:text-stone-300 transition-colors"
class="text-ghost-500 hover:text-ghost-300 transition-colors"
>
Contact
</a>

View file

@ -1,27 +1,38 @@
<template>
<nav
class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
:class="[
isMobile
? 'w-full flex flex-col bg-transparent'
: 'w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col bg-ghost-900 border-r border-ghost-700',
]"
>
<!-- Logo/Brand at top -->
<div class="p-8 border-b border-ghost-800 bg-blue-400">
<!-- Logo/Brand at top (desktop only) -->
<div v-if="!isMobile" class="p-8 border-b border-ghost-700 bg-primary-500">
<NuxtLink to="/" class="flex flex-col items-center gap-3 group">
<span
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
<span class="text-xl font-bold text-white ethereal-text tracking-wider"
>Ghost Guild Logo</span
>
</NuxtLink>
</div>
<!-- Vertical Navigation -->
<div class="flex-1 p-8 overflow-y-auto">
<ul class="space-y-6">
<div
:class="
isMobile ? 'flex-1 p-6 overflow-y-auto' : 'flex-1 p-8 overflow-y-auto'
"
>
<ul :class="isMobile ? 'space-y-4' : 'space-y-6'">
<li v-for="item in navigationItems" :key="item.path">
<NuxtLink :to="item.path" class="block group relative">
<NuxtLink
:to="item.path"
class="block group relative"
@click="handleNavigate"
>
<!-- Hover indicator -->
<span
class="text-stone-200 hover:text-stone-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
active-class="text-stone-100 ethereal-text translate-x-2"
class="text-ghost-200 hover:text-ghost-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
active-class="text-ghost-100 ethereal-text translate-x-2"
>
{{ item.label }}
</span>
@ -29,12 +40,24 @@
</li>
</ul>
<!-- Color Mode Switcher -->
<div class="mb-6">
<UColorModeButton size="md" class="w-full" />
</div>
<!-- Auth section -->
<div class="mt-12 pt-8 border-t border-ghost-800/50">
<div
:class="
isMobile
? 'mt-8 pt-6 border-t border-ghost-800/50'
: 'mt-12 pt-8 border-t border-ghost-800/50'
"
>
<div v-if="isAuthenticated" class="space-y-4">
<NuxtLink
to="/member/dashboard"
class="block text-stone-300 hover:text-stone-100 hover:ethereal-text transition-all duration-300 py-2"
class="block text-ghost-300 hover:text-ghost-100 hover:ethereal-text transition-all duration-300 py-2"
@click="handleNavigate"
>
<span class="block text-sm text-whisper-400 mb-1">{{
memberData?.name || "Member"
@ -42,14 +65,14 @@
Dashboard
</NuxtLink>
<button
@click="logout"
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm"
@click="handleLogout"
class="text-ghost-500 hover:text-ghost-300 transition-all duration-300 text-sm"
>
Logout
</button>
</div>
<div v-else class="space-y-4">
<p class="text-stone-400 text-sm mb-4">
<p class="text-ghost-400 text-sm mb-4">
Enter your email to receive a login link
</p>
@ -97,8 +120,30 @@
<script setup>
import { reactive, ref, computed } from "vue";
const props = defineProps({
isMobile: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["navigate"]);
const { isAuthenticated, logout, memberData } = useAuth();
const handleNavigate = () => {
if (props.isMobile) {
emit("navigate");
}
};
const handleLogout = async () => {
await logout();
if (props.isMobile) {
emit("navigate");
}
};
const publicNavigationItems = [
{ label: "Home", path: "/", accent: "entry point" },
{ label: "About", path: "/about", accent: "who we are" },

View file

@ -2,14 +2,14 @@
<div class="space-y-4">
<!-- Current Image Preview -->
<div v-if="modelValue?.url" class="relative">
<img
:src="transformedImageUrl"
<img
:src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover rounded-lg border border-gray-300"
class="w-full h-48 object-cover rounded-lg border border-neutral-200"
@error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)"
/>
<button
<button
@click="removeImage"
type="button"
class="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
@ -19,7 +19,7 @@
</div>
<!-- Upload Area -->
<div
<div
v-if="!modelValue?.url"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
@dragover.prevent="isDragging = true"
@ -34,12 +34,12 @@
@change="handleFileSelect"
class="hidden"
/>
<div class="space-y-3">
<Icon name="heroicons:photo" class="w-12 h-12 text-gray-400 mx-auto" />
<div>
<p class="text-gray-600">
<button
<button
type="button"
@click="$refs.fileInput.click()"
class="text-blue-600 hover:text-blue-500 font-medium"
@ -62,7 +62,7 @@
:value="modelValue.alt || ''"
@input="updateAltText($event.target.value)"
placeholder="Describe this image..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full border border-neutral-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -73,7 +73,7 @@
<span class="text-gray-600">{{ uploadProgress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`"
/>
@ -91,111 +91,113 @@
const props = defineProps({
modelValue: {
type: Object,
default: () => null
}
})
default: () => null,
},
});
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(["update:modelValue"]);
const isDragging = ref(false)
const isUploading = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const fileInput = ref()
const isDragging = ref(false);
const isUploading = ref(false);
const uploadProgress = ref(0);
const errorMessage = ref("");
const fileInput = ref();
// Transform image URL for preview (smaller size)
const transformedImageUrl = computed(() => {
console.log('modelValue in computed:', props.modelValue)
console.log("modelValue in computed:", props.modelValue);
// If we have the direct URL, use it
if (props.modelValue?.url) {
console.log('Using direct URL:', props.modelValue.url)
return props.modelValue.url
console.log("Using direct URL:", props.modelValue.url);
return props.modelValue.url;
}
// Otherwise try to construct from publicId
if (props.modelValue?.publicId) {
const config = useRuntimeConfig()
const constructedUrl = `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_400,h_200,c_fill,f_auto,q_auto/${props.modelValue.publicId}`
console.log('Constructed URL:', constructedUrl)
return constructedUrl
const config = useRuntimeConfig();
const constructedUrl = `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_400,h_200,c_fill,f_auto,q_auto/${props.modelValue.publicId}`;
console.log("Constructed URL:", constructedUrl);
return constructedUrl;
}
console.log('No URL or publicId found')
return ''
})
console.log("No URL or publicId found");
return "";
});
const handleFileSelect = (event) => {
const file = event.target.files[0]
const file = event.target.files[0];
if (file) {
uploadFile(file)
uploadFile(file);
}
}
};
const handleDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer.files
isDragging.value = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
uploadFile(files[0])
uploadFile(files[0]);
}
}
};
const uploadFile = async (file) => {
// Validate file
if (!file.type.startsWith('image/')) {
errorMessage.value = 'Please select an image file'
return
if (!file.type.startsWith("image/")) {
errorMessage.value = "Please select an image file";
return;
}
if (file.size > 10 * 1024 * 1024) { // 10MB
errorMessage.value = 'File size must be less than 10MB'
return
if (file.size > 10 * 1024 * 1024) {
// 10MB
errorMessage.value = "File size must be less than 10MB";
return;
}
errorMessage.value = ''
isUploading.value = true
uploadProgress.value = 0
errorMessage.value = "";
isUploading.value = true;
uploadProgress.value = 0;
try {
// Create form data for upload
const formData = new FormData()
formData.append('file', file)
const formData = new FormData();
formData.append("file", file);
// Upload to Cloudinary
const response = await $fetch(`/api/upload/image`, {
method: 'POST',
method: "POST",
body: formData,
onUploadProgress: (progress) => {
uploadProgress.value = Math.round((progress.loaded / progress.total) * 100)
}
})
uploadProgress.value = Math.round(
(progress.loaded / progress.total) * 100,
);
},
});
console.log('Upload response:', response)
console.log("Upload response:", response);
// Update the model value
emit('update:modelValue', {
emit("update:modelValue", {
url: response.secure_url,
publicId: response.public_id,
alt: ''
})
alt: "",
});
} catch (error) {
console.error('Upload failed:', error)
errorMessage.value = 'Upload failed. Please try again.'
console.error("Upload failed:", error);
errorMessage.value = "Upload failed. Please try again.";
} finally {
isUploading.value = false
uploadProgress.value = 0
isUploading.value = false;
uploadProgress.value = 0;
}
}
};
const removeImage = () => {
emit('update:modelValue', null)
}
emit("update:modelValue", null);
};
const updateAltText = (altText) => {
emit('update:modelValue', {
emit("update:modelValue", {
...props.modelValue,
alt: altText
})
}
</script>
alt: altText,
});
};
</script>

View file

@ -28,21 +28,21 @@
'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm',
props.theme === 'ethereal'
? 'bg-ghost-800/60 border border-ghost-700 ethereal-glow halftone-texture'
: 'bg-white dark:bg-gray-800 shadow-xl border border-blue-200 dark:border-blue-800',
: 'bg-[--ui-bg-elevated] shadow-xl border border-blue-200',
]"
>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3 md:gap-4">
<button
:class="[
'p-3 rounded-full transition-all duration-300',
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
? 'bg-whisper-600/80 text-ghost-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('prev')"
>
<svg
class="w-6 h-6"
class="w-5 h-5 md:w-6 md:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -56,14 +56,14 @@
</svg>
</button>
<div class="text-center flex-1">
<div class="text-center flex-1 min-w-0">
<slot name="interactive-content">
<p
:class="[
'text-lg',
'text-base md:text-lg',
props.theme === 'ethereal'
? 'text-stone-200'
: 'text-gray-600 dark:text-gray-300',
? 'text-ghost-200'
: 'text-[--ui-text-muted]',
]"
>
{{
@ -76,15 +76,15 @@
<button
:class="[
'p-3 rounded-full transition-all duration-300',
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
? 'bg-whisper-600/80 text-ghost-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('next')"
>
<svg
class="w-6 h-6"
class="w-5 h-5 md:w-6 md:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -178,12 +178,10 @@ defineEmits(["prev", "next"]);
const backgroundClass = computed(() => {
const themes = {
blue: "bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800",
purple:
"bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20",
emerald:
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20",
gray: "bg-gray-50 dark:bg-gray-900",
blue: "bg-gradient-to-br from-blue-50 to-indigo-100",
purple: "bg-gradient-to-br from-purple-50 to-violet-100",
emerald: "bg-gradient-to-br from-emerald-50 to-teal-100",
gray: "bg-neutral-100",
ethereal:
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
};

View file

@ -1,6 +1,6 @@
<template>
<div class="flex items-center gap-3 text-sm">
<span class="text-stone-300 font-medium">{{ label }}:</span>
<span class="text-ghost-300 font-medium">{{ label }}:</span>
<UButtonGroup size="sm" class="privacy-toggle-group">
<UButton
:variant="modelValue === 'members' ? 'solid' : 'outline'"
@ -46,14 +46,14 @@ const updateValue = (value) => {
<style scoped>
/* Unselected buttons - lighter background for visibility */
:deep(.privacy-toggle-btn:not(.is-selected)) {
background-color: rgb(68 64 60) !important; /* stone-700 */
border-color: rgb(87 83 78) !important; /* stone-600 */
color: rgb(214 211 209) !important; /* stone-300 */
background-color: rgb(68 64 60) !important; /* ghost-700 equivalent */
border-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
color: rgb(214 211 209) !important; /* ghost-300 equivalent */
}
:deep(.privacy-toggle-btn:not(.is-selected):hover) {
background-color: rgb(87 83 78) !important; /* stone-600 */
border-color: rgb(120 113 108) !important; /* stone-500 */
background-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
border-color: rgb(120 113 108) !important; /* ghost-500 equivalent */
}
/* Selected buttons - bright blue to stand out */

View file

@ -12,7 +12,7 @@
/>
<div
v-else
class="w-12 h-12 rounded-full bg-stone-700 flex items-center justify-center text-stone-300 font-bold"
class="w-12 h-12 rounded-full bg-ghost-700 flex items-center justify-center text-ghost-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
@ -23,30 +23,30 @@
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-stone-100">
<h3 class="font-semibold text-ghost-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-stone-300 transition-colors"
class="hover:text-ghost-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-stone-400">
<div class="flex items-center gap-2 text-sm text-ghost-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-stone-500">(edited)</span>
<span v-if="isEdited" class="text-ghost-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
class="px-2 py-0.5 bg-ghost-700 text-ghost-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
class="px-2 py-0.5 bg-ghost-700 text-ghost-300 rounded text-xs"
>
Public
</span>
@ -73,12 +73,12 @@
</div>
<!-- Content -->
<div class="text-stone-200 whitespace-pre-wrap break-words mb-3">
<div class="text-ghost-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-stone-400 hover:text-stone-300 ml-1"
class="text-ghost-400 hover:text-ghost-300 ml-1"
>
Read more
</NuxtLink>
@ -100,14 +100,14 @@
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-stone-400">
<div class="flex items-center gap-4 text-sm text-ghost-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-stone-300 transition-colors"
class="hover:text-ghost-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-stone-500">
<span v-if="update.commentsEnabled" class="text-ghost-500">
Comments (coming soon)
</span>
</div>

View file

@ -11,19 +11,19 @@
</UFormField>
<!-- Privacy Settings -->
<div class="border border-stone-700 rounded-lg p-4 bg-stone-800/30">
<h3 class="text-sm font-medium text-stone-200 mb-4">Privacy Settings</h3>
<div class="border border-ghost-700 rounded-lg p-4 bg-ghost-800/30">
<h3 class="text-sm font-medium text-ghost-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-stone-400"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Public</div>
<div class="text-sm text-stone-400">
<div class="text-ghost-200 font-medium">Public</div>
<div class="text-sm text-ghost-400">
Visible to everyone, including non-members
</div>
</div>
@ -34,11 +34,11 @@
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-stone-400"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Members Only</div>
<div class="text-sm text-stone-400">
<div class="text-ghost-200 font-medium">Members Only</div>
<div class="text-sm text-ghost-400">
Only visible to Ghost Guild members
</div>
</div>
@ -49,11 +49,11 @@
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-stone-400"
class="w-4 h-4 text-ghost-400"
/>
<div>
<div class="text-stone-200 font-medium">Private</div>
<div class="text-sm text-stone-400">Only visible to you</div>
<div class="text-ghost-200 font-medium">Private</div>
<div class="text-sm text-ghost-400">Only visible to you</div>
</div>
</label>
</div>
@ -66,15 +66,17 @@
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-stone-200 font-medium">Enable Comments</div>
<div class="text-sm text-stone-400">
<div class="text-ghost-200 font-medium">Enable Comments</div>
<div class="text-sm text-ghost-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-4 border-t border-stone-700">
<div
class="flex justify-between items-center pt-4 border-t border-ghost-700"
>
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>