feat(call): add ringtone management features including upload, preview, and settings for custom ringtones; enhance identity management with a new identities page for switching and deleting identities
This commit is contained in:
@@ -66,6 +66,14 @@
|
||||
</button>
|
||||
<LanguageSelector @language-change="onLanguageChange" />
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
:title="$t('app.audio_calls')"
|
||||
@click="$router.push({ name: 'call' })"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone" class="w-6 h-6" />
|
||||
</button>
|
||||
<button type="button" class="rounded-full" @click="syncPropagationNode">
|
||||
<span
|
||||
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition"
|
||||
@@ -165,6 +173,16 @@
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- telephone -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'call' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="phone" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.audio_calls") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- interfaces -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'interfaces' }">
|
||||
@@ -205,6 +223,16 @@
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- identities -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'identities' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="account-multiple" class="size-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.identities") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- info -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'about' }">
|
||||
@@ -396,6 +424,29 @@
|
||||
</template>
|
||||
<CallOverlay v-if="activeCall || isCallEnded" :active-call="activeCall || lastCall" :is-ended="isCallEnded" />
|
||||
<Toast />
|
||||
|
||||
<!-- identity switching overlay -->
|
||||
<transition name="fade-blur">
|
||||
<div
|
||||
v-if="isSwitchingIdentity"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-white/10 dark:bg-black/10 backdrop-blur-md"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-20 h-20 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<MaterialDesignIcon icon-name="account-sync" class="w-8 h-8 text-blue-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
Switching Identity...
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading your identity</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -439,6 +490,8 @@ export default {
|
||||
|
||||
isSidebarOpen: false,
|
||||
|
||||
isSwitchingIdentity: false,
|
||||
|
||||
displayName: "Anonymous Peer",
|
||||
config: null,
|
||||
appInfo: null,
|
||||
@@ -449,6 +502,7 @@ export default {
|
||||
isCallEnded: false,
|
||||
lastCall: null,
|
||||
endedTimeout: null,
|
||||
ringtonePlayer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -485,6 +539,9 @@ export default {
|
||||
if (newConfig && newConfig.language) {
|
||||
this.$i18n.locale = newConfig.language;
|
||||
}
|
||||
if (newConfig && newConfig.custom_ringtone_enabled !== undefined) {
|
||||
this.updateRingtonePlayer();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
@@ -493,6 +550,7 @@ export default {
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.appInfoInterval);
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
this.stopRingtone();
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -501,8 +559,20 @@ export default {
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
// listen for identity switching events
|
||||
GlobalEmitter.on("identity-switching-start", () => {
|
||||
this.isSwitchingIdentity = true;
|
||||
// safety timeout to hide overlay if something goes wrong
|
||||
setTimeout(() => {
|
||||
if (this.isSwitchingIdentity) {
|
||||
this.isSwitchingIdentity = false;
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.updateRingtonePlayer();
|
||||
this.updateTelephoneStatus();
|
||||
this.updatePropagationNodeStatus();
|
||||
|
||||
@@ -537,6 +607,31 @@ export default {
|
||||
case "telephone_ringing": {
|
||||
NotificationUtils.showIncomingCallNotification();
|
||||
this.updateTelephoneStatus();
|
||||
this.playRingtone();
|
||||
break;
|
||||
}
|
||||
case "telephone_call_established":
|
||||
case "telephone_call_ended": {
|
||||
this.stopRingtone();
|
||||
this.updateTelephoneStatus();
|
||||
break;
|
||||
}
|
||||
case "identity_switched": {
|
||||
ToastUtils.success(`Switched to identity: ${json.display_name}`);
|
||||
|
||||
// reset global state
|
||||
GlobalState.unreadConversationsCount = 0;
|
||||
|
||||
// update local state
|
||||
await this.getConfig();
|
||||
await this.updateRingtonePlayer();
|
||||
await this.getAppInfo();
|
||||
|
||||
// hide loading overlay
|
||||
this.isSwitchingIdentity = false;
|
||||
|
||||
// if we are on identities page, we might want to refresh it
|
||||
GlobalEmitter.emit("identity-switched", json);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -686,6 +781,39 @@ export default {
|
||||
formatSecondsAgo: function (seconds) {
|
||||
return Utils.formatSecondsAgo(seconds);
|
||||
},
|
||||
async updateRingtonePlayer() {
|
||||
// Stop current player if any
|
||||
if (this.ringtonePlayer) {
|
||||
this.ringtonePlayer.pause();
|
||||
this.ringtonePlayer = null;
|
||||
}
|
||||
|
||||
if (this.config?.custom_ringtone_enabled) {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/ringtones/status");
|
||||
const status = response.data;
|
||||
if (status.has_custom_ringtone && status.id) {
|
||||
this.ringtonePlayer = new Audio(`/api/v1/telephone/ringtones/${status.id}/audio`);
|
||||
this.ringtonePlayer.loop = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update ringtone player:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
playRingtone() {
|
||||
if (this.ringtonePlayer) {
|
||||
this.ringtonePlayer.play().catch((e) => {
|
||||
console.log("Failed to play custom ringtone:", e);
|
||||
});
|
||||
}
|
||||
},
|
||||
stopRingtone() {
|
||||
if (this.ringtonePlayer) {
|
||||
this.ringtonePlayer.pause();
|
||||
this.ringtonePlayer.currentTime = 0;
|
||||
}
|
||||
},
|
||||
async updateTelephoneStatus() {
|
||||
try {
|
||||
// fetch status
|
||||
@@ -696,6 +824,11 @@ export default {
|
||||
this.activeCall = response.data.active_call;
|
||||
this.isTelephoneCallActive = this.activeCall != null;
|
||||
|
||||
// Stop ringtone if not ringing anymore
|
||||
if (this.activeCall?.status !== 4) {
|
||||
this.stopRingtone();
|
||||
}
|
||||
|
||||
// If call just ended, show ended state for a few seconds
|
||||
if (oldCall != null && this.activeCall == null) {
|
||||
this.lastCall = oldCall;
|
||||
@@ -743,3 +876,16 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-blur-enter-active,
|
||||
.fade-blur-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-blur-enter-from,
|
||||
.fade-blur-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
class="p-2 rounded"
|
||||
:style="{ color: iconForegroundColour, 'background-color': iconBackgroundColour }"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="iconName" class="size-6" />
|
||||
<MaterialDesignIcon :icon-name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="account-outline" :class="iconClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +31,10 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
default: "size-6",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -31,6 +31,17 @@
|
||||
>{{ unreadVoicemailsCount }}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
activeTab === 'ringtone'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
|
||||
]"
|
||||
class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
|
||||
@click="activeTab = 'ringtone'"
|
||||
>
|
||||
{{ $t("call.ringtone") }}
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
activeTab === 'settings'
|
||||
@@ -264,7 +275,16 @@
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
|
||||
Call History
|
||||
</h3>
|
||||
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] text-gray-400 hover:text-red-500 font-bold uppercase tracking-tighter transition-colors"
|
||||
@click="clearHistory"
|
||||
>
|
||||
{{ $t("app.clear_history") }}
|
||||
</button>
|
||||
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
|
||||
<li
|
||||
@@ -433,6 +453,145 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ringtone Tab -->
|
||||
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6">
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-zinc-800"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-2"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="music" class="size-5 text-blue-500" />
|
||||
{{ $t("call.ringtone_settings") }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Enabled Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("call.enable_custom_ringtone") }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("call.enable_custom_ringtone_description") }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
|
||||
:class="config.custom_ringtone_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'"
|
||||
@click="
|
||||
config.custom_ringtone_enabled = !config.custom_ringtone_enabled;
|
||||
updateConfig({ custom_ringtone_enabled: config.custom_ringtone_enabled });
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="config.custom_ringtone_enabled ? 'translate-x-5' : 'translate-x-0'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ringtone List -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
|
||||
My Ringtones
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||
@click="$refs.ringtoneUpload.click()"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="size-4" />
|
||||
Upload New
|
||||
</button>
|
||||
<input
|
||||
ref="ringtoneUpload"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="audio/*"
|
||||
@change="uploadRingtone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="ringtones.length > 0" class="grid gap-3">
|
||||
<div
|
||||
v-for="ringtone in ringtones"
|
||||
:key="ringtone.id"
|
||||
class="group p-4 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-800/30 flex items-center gap-4 transition-all hover:shadow-md"
|
||||
:class="{ 'ring-2 ring-blue-500/20 bg-blue-50/20 dark:bg-blue-900/10': ringtone.is_primary }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="editingRingtoneId === ringtone.id" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="editingRingtoneName"
|
||||
class="text-sm bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded px-2 py-1 flex-1"
|
||||
@keyup.enter="saveRingtoneName"
|
||||
@blur="saveRingtoneName"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ ringtone.display_name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ringtone.is_primary"
|
||||
class="text-[10px] uppercase font-bold text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 rounded"
|
||||
>
|
||||
Primary
|
||||
</span>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-blue-500 transition-opacity"
|
||||
@click="startEditingRingtone(ringtone)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="pencil" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-zinc-500 truncate">
|
||||
{{ ringtone.filename }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-white dark:hover:bg-zinc-800 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
:title="isPlayingRingtone && playingRingtoneId === ringtone.id ? 'Stop' : 'Preview'"
|
||||
@click="playRingtonePreview(ringtone)"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isPlayingRingtone && playingRingtoneId === ringtone.id ? 'stop' : 'play'"
|
||||
class="size-5"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="!ringtone.is_primary"
|
||||
class="p-2 rounded-lg hover:bg-white dark:hover:bg-zinc-800 text-gray-500 dark:text-gray-400 hover:text-blue-500 transition-colors"
|
||||
title="Set as Primary"
|
||||
@click="setPrimaryRingtone(ringtone)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="star-outline" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-white dark:hover:bg-zinc-800 text-gray-500 dark:text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Delete"
|
||||
@click="deleteRingtone(ringtone)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-outline" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-100 dark:border-zinc-800 rounded-2xl bg-gray-50/30 dark:bg-zinc-900/20">
|
||||
<MaterialDesignIcon icon-name="music-off" class="size-8 text-gray-300 dark:text-zinc-700 mb-2" />
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-500">
|
||||
{{ $t("call.no_custom_ringtone_uploaded") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div v-if="activeTab === 'settings' && config" class="flex-1 space-y-6">
|
||||
<div
|
||||
@@ -658,9 +817,18 @@ export default {
|
||||
},
|
||||
isGeneratingGreeting: false,
|
||||
isUploadingGreeting: false,
|
||||
isUploadingRingtone: false,
|
||||
playingVoicemailId: null,
|
||||
audioPlayer: null,
|
||||
isPlayingGreeting: false,
|
||||
isPlayingRingtone: false,
|
||||
ringtoneStatus: {
|
||||
has_custom_ringtone: false,
|
||||
enabled: false,
|
||||
},
|
||||
ringtones: [],
|
||||
editingRingtoneId: null,
|
||||
editingRingtoneName: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -671,19 +839,22 @@ export default {
|
||||
return this.activeCall?.is_speaker_muted ?? false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
this.getAudioProfiles();
|
||||
this.getStatus();
|
||||
this.getHistory();
|
||||
this.getVoicemails();
|
||||
this.getVoicemailStatus();
|
||||
|
||||
// poll for status
|
||||
this.statusInterval = setInterval(() => {
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
this.getAudioProfiles();
|
||||
this.getStatus();
|
||||
this.getHistory();
|
||||
this.getVoicemails();
|
||||
this.getVoicemailStatus();
|
||||
}, 1000);
|
||||
this.getRingtones();
|
||||
this.getRingtoneStatus();
|
||||
|
||||
// poll for status
|
||||
this.statusInterval = setInterval(() => {
|
||||
this.getStatus();
|
||||
this.getVoicemailStatus();
|
||||
this.getRingtoneStatus();
|
||||
}, 1000);
|
||||
|
||||
// poll for history/voicemails less frequently
|
||||
this.historyInterval = setInterval(() => {
|
||||
@@ -786,6 +957,17 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async clearHistory() {
|
||||
if (!confirm(this.$t("common.delete_confirm"))) return;
|
||||
try {
|
||||
await window.axios.delete("/api/v1/telephone/history");
|
||||
this.callHistory = [];
|
||||
ToastUtils.success("Call history cleared");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to clear call history");
|
||||
}
|
||||
},
|
||||
async getVoicemailStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/voicemail/status");
|
||||
@@ -794,6 +976,115 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getRingtoneStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/ringtones/status");
|
||||
this.ringtoneStatus = response.data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getRingtones() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/ringtones");
|
||||
this.ringtones = response.data;
|
||||
} catch (e) {
|
||||
console.error("Failed to get ringtones:", e);
|
||||
}
|
||||
},
|
||||
async deleteRingtone(ringtone) {
|
||||
if (!confirm(this.$t("common.delete_confirm"))) return;
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/telephone/ringtones/${ringtone.id}`);
|
||||
ToastUtils.success(this.$t("call.ringtone_deleted"));
|
||||
await this.getRingtones();
|
||||
await this.getRingtoneStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("call.failed_to_delete_ringtone"));
|
||||
}
|
||||
},
|
||||
async setPrimaryRingtone(ringtone) {
|
||||
try {
|
||||
await window.axios.patch(`/api/v1/telephone/ringtones/${ringtone.id}`, {
|
||||
is_primary: true,
|
||||
});
|
||||
ToastUtils.success(this.$t("call.primary_ringtone_set"));
|
||||
await this.getRingtones();
|
||||
await this.getRingtoneStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("call.failed_to_set_primary_ringtone"));
|
||||
}
|
||||
},
|
||||
startEditingRingtone(ringtone) {
|
||||
this.editingRingtoneId = ringtone.id;
|
||||
this.editingRingtoneName = ringtone.display_name;
|
||||
},
|
||||
async saveRingtoneName() {
|
||||
try {
|
||||
await window.axios.patch(`/api/v1/telephone/ringtones/${this.editingRingtoneId}`, {
|
||||
display_name: this.editingRingtoneName,
|
||||
});
|
||||
this.editingRingtoneId = null;
|
||||
await this.getRingtones();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("call.failed_to_update_ringtone_name"));
|
||||
}
|
||||
},
|
||||
async uploadRingtone(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
this.isUploadingRingtone = true;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
await window.axios.post("/api/v1/telephone/ringtones/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
ToastUtils.success(this.$t("call.ringtone_uploaded_successfully"));
|
||||
await this.getRingtones();
|
||||
await this.getRingtoneStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(e.response?.data?.message || this.$t("call.failed_to_upload_ringtone"));
|
||||
} finally {
|
||||
this.isUploadingRingtone = false;
|
||||
event.target.value = "";
|
||||
}
|
||||
},
|
||||
async playRingtonePreview(ringtone) {
|
||||
if (this.isPlayingRingtone && this.playingRingtoneId === ringtone.id) {
|
||||
this.audioPlayer.pause();
|
||||
this.isPlayingRingtone = false;
|
||||
this.playingRingtoneId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.pause();
|
||||
}
|
||||
|
||||
this.playingRingtoneId = ringtone.id;
|
||||
this.audioPlayer = new Audio(`/api/v1/telephone/ringtones/${ringtone.id}/audio`);
|
||||
this.audioPlayer.onended = () => {
|
||||
this.isPlayingRingtone = false;
|
||||
this.playingRingtoneId = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await this.audioPlayer.play();
|
||||
this.isPlayingRingtone = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("call.failed_to_play_ringtone"));
|
||||
}
|
||||
},
|
||||
async getVoicemails() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/voicemails");
|
||||
@@ -845,7 +1136,7 @@ export default {
|
||||
await window.axios.delete("/api/v1/telephone/voicemail/greeting");
|
||||
ToastUtils.success("Greeting deleted");
|
||||
await this.getVoicemailStatus();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete greeting");
|
||||
}
|
||||
},
|
||||
@@ -901,7 +1192,7 @@ export default {
|
||||
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.");
|
||||
ToastUtils.error(this.$t("call.no_greeting_audio_found"));
|
||||
this.isPlayingGreeting = false;
|
||||
});
|
||||
this.audioPlayer.onended = () => {
|
||||
|
||||
@@ -638,7 +638,7 @@ import XYZ from "ol/source/XYZ";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import Feature from "ol/Feature";
|
||||
import Point from "ol/geom/Point";
|
||||
import { Style, Icon, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
|
||||
import { Style, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
|
||||
import { fromLonLat, toLonLat } from "ol/proj";
|
||||
import { defaults as defaultControls } from "ol/control";
|
||||
import DragBox from "ol/interaction/DragBox";
|
||||
|
||||
312
meshchatx/src/frontend/components/settings/IdentitiesPage.vue
Normal file
312
meshchatx/src/frontend/components/settings/IdentitiesPage.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<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="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
|
||||
<div class="space-y-6 w-full max-w-4xl mx-auto">
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{{ $t("identities.title") }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ $t("identities.manage") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg hover:shadow-indigo-500/25 transition-all active:scale-95"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="w-5 h-5" />
|
||||
{{ $t("identities.new_identity") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- identities list -->
|
||||
<div class="grid gap-4">
|
||||
<div
|
||||
v-for="identity in identities"
|
||||
:key="identity.hash"
|
||||
class="glass-card overflow-hidden group transition-all duration-300"
|
||||
:class="{
|
||||
'ring-2 ring-blue-500/50 dark:ring-blue-400/40 bg-blue-50/30 dark:bg-blue-900/10':
|
||||
identity.is_current,
|
||||
}"
|
||||
>
|
||||
<div class="p-5 flex items-center gap-4">
|
||||
<!-- icon -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-14 h-14 rounded-2xl flex items-center justify-center shadow-inner overflow-hidden transition-all duration-500"
|
||||
:class="
|
||||
identity.is_current && !identity.icon_background_colour
|
||||
? 'bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900/50 dark:to-indigo-900/50'
|
||||
: !identity.icon_background_colour
|
||||
? 'bg-gradient-to-br from-gray-100 to-slate-100 dark:from-zinc-800 dark:to-zinc-800/50'
|
||||
: ''
|
||||
"
|
||||
:style="
|
||||
identity.icon_background_colour
|
||||
? { 'background-color': identity.icon_background_colour }
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
v-if="identity.icon_name"
|
||||
:icon-name="identity.icon_name"
|
||||
class="w-8 h-8"
|
||||
:style="{ color: identity.icon_foreground_colour || 'inherit' }"
|
||||
/>
|
||||
<MaterialDesignIcon
|
||||
v-else
|
||||
:icon-name="identity.is_current ? 'account-check' : 'account'"
|
||||
class="w-8 h-8"
|
||||
:class="
|
||||
identity.is_current
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="identity.is_current"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ identity.display_name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="identity.is_current"
|
||||
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[10px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
{{ $t("identities.current") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-mono text-gray-500 dark:text-zinc-500 truncate mt-0.5 tracking-tight"
|
||||
>
|
||||
{{ identity.hash }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="!identity.is_current"
|
||||
type="button"
|
||||
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 transition-all active:scale-90"
|
||||
:title="$t('identities.switch')"
|
||||
@click="switchIdentity(identity)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="swap-horizontal" class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!identity.is_current"
|
||||
type="button"
|
||||
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-red-500 hover:text-white dark:hover:bg-red-600 transition-all active:scale-90"
|
||||
:title="$t('identities.delete')"
|
||||
@click="deleteIdentity(identity)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-outline" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- empty state -->
|
||||
<div v-if="identities.length === 0" class="glass-card p-12 text-center">
|
||||
<div
|
||||
class="w-20 h-20 bg-gray-100 dark:bg-zinc-800 rounded-3xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-group" class="w-10 h-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("identities.no_identities") }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ $t("identities.create_first") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- create modal -->
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
>
|
||||
<div class="glass-card w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("identities.new_identity") }}
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ $t("identities.generate_fresh") }}</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{{ $t("identities.display_name") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newIdentityName"
|
||||
type="text"
|
||||
:placeholder="$t('identities.display_name_hint')"
|
||||
class="input-field"
|
||||
autofocus
|
||||
@keyup.enter="createIdentity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
|
||||
@click="showCreateModal = false"
|
||||
>
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-semibold shadow-lg shadow-blue-500/25 hover:bg-blue-500 transition active:scale-95"
|
||||
:disabled="isCreating"
|
||||
@click="createIdentity"
|
||||
>
|
||||
<span v-if="isCreating" class="animate-spin mr-2">
|
||||
<MaterialDesignIcon icon-name="loading" class="w-4 h-4" />
|
||||
</span>
|
||||
{{ $t("common.add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
|
||||
export default {
|
||||
name: "IdentitiesPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
identities: [],
|
||||
showCreateModal: false,
|
||||
newIdentityName: "",
|
||||
isCreating: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getIdentities();
|
||||
GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
|
||||
},
|
||||
beforeUnmount() {
|
||||
GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
|
||||
},
|
||||
methods: {
|
||||
onIdentitySwitched() {
|
||||
this.getIdentities();
|
||||
this.isCreating = false;
|
||||
},
|
||||
async getIdentities() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/identities");
|
||||
this.identities = response.data.identities;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to load identities");
|
||||
}
|
||||
},
|
||||
async createIdentity() {
|
||||
if (!this.newIdentityName) {
|
||||
ToastUtils.warning("Please enter a display name");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCreating = true;
|
||||
try {
|
||||
await window.axios.post("/api/v1/identities/create", {
|
||||
display_name: this.newIdentityName,
|
||||
});
|
||||
ToastUtils.success("Identity created successfully");
|
||||
this.showCreateModal = false;
|
||||
this.newIdentityName = "";
|
||||
await this.getIdentities();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to create identity");
|
||||
} finally {
|
||||
this.isCreating = false;
|
||||
}
|
||||
},
|
||||
async switchIdentity(identity) {
|
||||
if (!(await DialogUtils.confirm(this.$t("identities.switch_confirm", { name: identity.display_name })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isCreating = true; // Use isCreating as a general loading state for now
|
||||
GlobalEmitter.emit("identity-switching-start");
|
||||
|
||||
const response = await window.axios.post("/api/v1/identities/switch", {
|
||||
identity_hash: identity.hash,
|
||||
});
|
||||
|
||||
if (response.data.hotswapped) {
|
||||
// ToastUtils.success(this.$t("identities.switched")); // Removed as App.vue handles this now
|
||||
// The App.vue will handle the event and we will refresh via GlobalEmitter
|
||||
} else {
|
||||
ToastUtils.success("Switch scheduled. Reloading...");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to switch identity");
|
||||
this.isCreating = false;
|
||||
// Important: hide the global overlay if there was an error
|
||||
// We'll emit an event for this or just hide it here if we had access,
|
||||
// but since it's global, let's just refresh the whole state.
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
async deleteIdentity(identity) {
|
||||
if (!(await DialogUtils.confirm(this.$t("identities.delete_confirm", { name: identity.display_name })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/identities/${identity.hash}`);
|
||||
ToastUtils.success(this.$t("identities.deleted"));
|
||||
await this.getIdentities();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to delete identity");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user