Add demo mode indicator, improve layout responsiveness, and enhance notification handling

This commit is contained in:
2026-01-01 16:18:13 -06:00
parent 7ff6a5e9d3
commit 8d83049ba2
7 changed files with 106 additions and 39 deletions

View File

@@ -3,6 +3,10 @@
:class="{ dark: config?.theme === 'dark' }"
class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors"
>
<div v-if="appInfo?.is_demo" class="relative z-[100] bg-blue-600/90 backdrop-blur-sm text-white text-[10px] font-bold uppercase tracking-[0.2em] py-1 text-center select-none border-b border-white/10 shadow-sm">
Demo Mode &bull; Read Only
</div>
<RouterView v-if="$route.name === 'auth'" />
<template v-else>
@@ -24,7 +28,7 @@
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner"
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner"
>
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo.png" />
</div>
@@ -562,6 +566,15 @@ export default {
await this.getConfig();
},
async updateConfig(config) {
// update local state immediately if in demo mode, as websocket is not available
if (this.appInfo?.is_demo) {
this.config = {
...this.config,
...config,
};
return;
}
try {
WebSocketConnection.send(
JSON.stringify({

View File

@@ -1,19 +1,19 @@
<template>
<div class="card-stack-wrapper" :class="{ 'is-expanded': isExpanded }">
<div class="card-stack-wrapper flex-1 flex flex-col min-h-0" :class="{ 'is-expanded': isExpanded }">
<div
v-if="items && items.length > 0"
class="relative"
:class="{ 'stack-mode': !isExpanded && items.length > 1, 'grid-mode': isExpanded || items.length === 1 }"
>
<!-- Grid Mode (Expanded or only 1 item) -->
<div v-if="isExpanded || items.length === 1" :class="gridClass">
<div v-if="isExpanded || items.length === 1" :class="gridClass" class="flex-1 min-h-0">
<div v-for="(item, index) in items" :key="index" class="w-full">
<slot :item="item" :index="index"></slot>
</div>
</div>
<!-- Stack Mode (Collapsed and > 1 item) -->
<div v-else class="relative" :style="{ height: stackHeight + 'px' }">
<div v-else class="relative flex-1 min-h-[320px]" :style="{ minHeight: stackHeight + 'px' }">
<div
v-for="(item, index) in stackedItems"
:key="index"

View File

@@ -146,14 +146,10 @@ export default {
isLoading: false,
notifications: [],
reloadInterval: null,
manuallyCleared: false,
};
},
computed: {
unreadCount() {
if (this.manuallyCleared) {
return 0;
}
return this.notifications.length;
},
},
@@ -173,11 +169,11 @@ export default {
}, 5000);
},
methods: {
toggleDropdown() {
async toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.loadNotifications();
this.manuallyCleared = true;
await this.loadNotifications();
await this.markNotificationsAsViewed();
}
},
closeDropdown() {
@@ -194,11 +190,6 @@ export default {
});
const newNotifications = response.data.conversations || [];
// if we have more notifications than before, show the red dot again
if (newNotifications.length > this.notifications.length) {
this.manuallyCleared = false;
}
this.notifications = newNotifications;
} catch (e) {
console.error("Failed to load notifications", e);
@@ -207,6 +198,19 @@ export default {
this.isLoading = false;
}
},
async markNotificationsAsViewed() {
if (this.notifications.length === 0) {
return;
}
try {
const destination_hashes = this.notifications.map((n) => n.destination_hash);
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
});
} catch (e) {
console.error("Failed to mark notifications as viewed", e);
}
},
onNotificationClick(notification) {
this.closeDropdown();
this.$router.push({
@@ -221,6 +225,10 @@ export default {
const json = JSON.parse(message.data);
if (json.type === "lxmf.delivery") {
await this.loadNotifications();
// If dropdown is open, mark new notifications as viewed
if (this.isDropdownOpen) {
await this.markNotificationsAsViewed();
}
}
},
},

View File

@@ -60,11 +60,11 @@
</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="group in groupedArchives" :key="group.destination_hash" class="relative">
<div class="sticky top-6">
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch">
<div v-for="group in groupedArchives" :key="group.destination_hash" class="relative flex">
<div class="sticky top-6 w-full flex flex-col">
<div
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden flex flex-col h-full min-h-[400px]"
>
<div
class="p-5 border-b border-gray-100 dark:border-zinc-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-zinc-800 dark:to-zinc-800/50"
@@ -87,11 +87,11 @@
{{ group.destination_hash.substring(0, 16) }}...
</p>
</div>
<div class="p-5 pb-6">
<div class="p-5 pb-6 flex-1 flex flex-col min-h-0">
<CardStack :items="group.archives" :max-visible="3">
<template #default="{ item: archive }">
<div
class="stacked-card bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg p-4 h-full hover:shadow-xl transition-all duration-200 cursor-pointer group"
class="stacked-card bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg p-4 h-full flex flex-col hover:shadow-xl transition-all duration-200 cursor-pointer group"
@click="viewArchive(archive)"
>
<div class="flex items-start justify-between mb-3">
@@ -117,11 +117,11 @@
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="text-xs text-gray-700 dark:text-gray-300 line-clamp-5 micron-preview leading-relaxed"
class="text-xs text-gray-700 dark:text-gray-300 line-clamp-5 micron-preview leading-relaxed flex-1 min-h-[120px]"
v-html="renderPreview(archive)"
></div>
<div
class="mt-3 pt-3 border-t border-gray-100 dark:border-zinc-700 flex items-center justify-between"
class="mt-3 pt-3 border-t border-gray-100 dark:border-zinc-700 flex items-center justify-between flex-shrink-0"
>
<div
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"

View File

@@ -5,7 +5,9 @@
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
>
<div class="text-center mb-8">
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo.png" />
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner flex items-center justify-center">
<img class="w-16 h-16 object-contain p-2" src="/assets/images/logo.png" />
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? "Initial Setup" : "Authentication Required" }}
</h1>

View File

@@ -45,8 +45,8 @@
<!-- Phone Tab -->
<div v-if="activeTab === 'phone'" class="flex-1 flex flex-col">
<div v-if="activeCall || isCallEnded" class="flex my-auto">
<div class="mx-auto my-auto min-w-64">
<div v-if="activeCall || isCallEnded" class="flex mt-8 mb-12">
<div class="mx-auto min-w-64">
<div class="text-center">
<div>
<!-- icon -->
@@ -224,7 +224,7 @@
</div>
</div>
<div v-else class="my-auto">
<div v-else class="mt-8 mb-12">
<div class="text-center mb-4">
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div>
<div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div>
@@ -249,7 +249,7 @@
</div>
<!-- Call History -->
<div v-if="callHistory.length > 0 && !activeCall && !isCallEnded" class="mt-8">
<div v-if="callHistory.length > 0 && !activeCall && !isCallEnded" class="mt-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
@@ -283,18 +283,25 @@
{{ entry.timestamp ? formatDateTime(entry.timestamp * 1000) : "" }}
</span>
</div>
<div class="flex items-center justify-between mt-0.5">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span>{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
<div class="flex items-start justify-between mt-0.5">
<div class="flex-1 min-w-0">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span class="capitalize">{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
>
</div>
<div
class="text-[10px] text-gray-400 dark:text-zinc-600 font-mono truncate mt-0.5"
>
{{ entry.remote_identity_hash }}
</div>
</div>
<button
type="button"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter ml-4"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
@@ -492,7 +499,11 @@
This text will be converted to speech using eSpeak NG.
</p>
<button
:disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
:disabled="
!voicemailStatus.has_espeak ||
!voicemailStatus.has_ffmpeg ||
isGeneratingGreeting
"
class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
@click="
updateConfig({ voicemail_greeting: config.voicemail_greeting });
@@ -501,6 +512,17 @@
>
{{ isGeneratingGreeting ? "Generating..." : "Save & Generate" }}
</button>
<button
v-if="voicemailStatus.has_espeak && voicemailStatus.has_ffmpeg"
class="text-[10px] bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-3 py-1 rounded-full font-bold hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors flex items-center gap-1"
@click="playGreeting"
>
<MaterialDesignIcon
:icon-name="isPlayingGreeting ? 'stop' : 'play'"
class="size-3"
/>
{{ isPlayingGreeting ? "Stop Preview" : "Preview Greeting" }}
</button>
</div>
</div>
@@ -582,6 +604,7 @@ export default {
isGeneratingGreeting: false,
playingVoicemailId: null,
audioPlayer: null,
isPlayingGreeting: false,
};
},
computed: {
@@ -773,6 +796,27 @@ export default {
ToastUtils.error("Failed to delete voicemail");
}
},
async playGreeting() {
if (this.isPlayingGreeting) {
this.audioPlayer.pause();
this.isPlayingGreeting = false;
return;
}
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.isPlayingGreeting = true;
this.audioPlayer = new Audio("/api/v1/telephone/voicemail/greeting/audio");
this.audioPlayer.play().catch(() => {
ToastUtils.error("No greeting audio found. Please generate one first.");
this.isPlayingGreeting = false;
});
this.audioPlayer.onended = () => {
this.isPlayingGreeting = false;
};
},
async call(identityHash) {
if (!identityHash) {
ToastUtils.error("Enter an identity hash to call");

View File

@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full">
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
<div
v-if="showRestartReminder"
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"