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:
2026-01-01 19:53:20 -06:00
parent f9605436c4
commit 668520e576
5 changed files with 770 additions and 17 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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";

View 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>