Add demo mode indicator, improve layout responsiveness, and enhance notification handling
This commit is contained in:
@@ -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 • 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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user