feat(profile): redesign ProfileIconPage with improved layout, color selection, and icon management features

This commit is contained in:
2026-01-01 23:35:56 -06:00
parent 5ca7308d66
commit 1d52056a2d

View File

@@ -1,102 +1,201 @@
<template> <template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950"> <div class="flex flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950">
<div class="overflow-y-auto space-y-2 p-2"> <div class="overflow-y-auto">
<!-- info --> <div class="max-w-4xl mx-auto p-4 space-y-6">
<div class="bg-white dark:bg-zinc-800 rounded shadow"> <!-- Header with Preview -->
<div <div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold" class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
> >
Customise your Profile Icon <div class="p-6 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Profile Icon Customizer</h2>
<p class="text-sm text-gray-500 dark:text-zinc-400 mt-1">
Customize your profile icon that appears in all your messages
</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
:disabled="!hasChanges || isSaving"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="
hasChanges && !isSaving
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:border-blue-500 dark:hover:bg-blue-600'
: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-700'
"
@click="saveChanges"
>
<MaterialDesignIcon
v-if="isSaving"
icon-name="refresh"
class="size-4 animate-spin"
/>
<MaterialDesignIcon v-else icon-name="content-save" class="size-4" />
{{ isSaving ? "Saving..." : "Save" }}
</button>
<button
type="button"
:disabled="!hasChanges || isSaving"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetChanges"
>
<MaterialDesignIcon icon-name="refresh" class="size-4" />
Reset
</button>
</div>
</div>
</div>
<div class="p-6">
<div class="flex flex-col items-center justify-center space-y-4">
<div class="text-sm font-medium text-gray-700 dark:text-zinc-300">Preview</div>
<div class="p-8 bg-gray-50 dark:bg-zinc-800 rounded-2xl">
<LxmfUserIcon
:icon-name="iconName"
:icon-foreground-colour="iconForegroundColour"
:icon-background-colour="iconBackgroundColour"
icon-class="size-16"
/>
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 text-center max-w-md">
This is how your icon will appear to others when you send messages
</div> </div>
<div class="text-gray-900 dark:text-gray-100">
<div class="text-sm p-2">
<ul class="list-disc list-inside">
<li>Personalise your profile with a custom coloured icon.</li>
<li>This icon will be sent in all of your outgoing messages.</li>
<li>When you send someone a message, they will see your new icon.</li>
<li>
You can
<span class="cursor-pointer underline text-blue-500" @click="removeProfileIcon"
>remove your icon</span
>, however it will still show for anyone that already received it.
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
<!-- colours --> <!-- Color Selection -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div <div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold" class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
> >
Select your Colours <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Colors</h3>
</div> </div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100"> <div class="p-4 space-y-4">
<!-- background colour --> <div class="flex items-center justify-between gap-4">
<div class="p-2 flex space-x-2"> <div class="flex-1">
<div class="flex my-auto"> <label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Background Color
</label>
<div class="flex items-center gap-3">
<ColourPickerDropdown v-model:colour="iconBackgroundColour" /> <ColourPickerDropdown v-model:colour="iconBackgroundColour" />
</div> <div class="flex-1">
<div class="my-auto"> <input
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div> v-model="iconBackgroundColour"
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconBackgroundColour }}</div> type="text"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="#e5e7eb"
/>
</div> </div>
</div> </div>
</div>
<!-- icon colour --> </div>
<div class="p-2 flex space-x-2"> <div class="flex items-center justify-between gap-4">
<div class="flex my-auto"> <div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Icon Color
</label>
<div class="flex items-center gap-3">
<ColourPickerDropdown v-model:colour="iconForegroundColour" /> <ColourPickerDropdown v-model:colour="iconForegroundColour" />
<div class="flex-1">
<input
v-model="iconForegroundColour"
type="text"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="#6b7280"
/>
</div>
</div> </div>
<div class="my-auto">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Colour</div>
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconForegroundColour }}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- search icons --> <!-- Icon Selection -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div <div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold" class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
> >
Select your Icon <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Icon</h3>
</div> </div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100"> <div class="p-4 space-y-4">
<div class="flex p-1"> <div class="relative">
<input <input
v-model="search" v-model="search"
type="text" type="text"
:placeholder="`Search ${iconNames.length} icons...`" :placeholder="`Search ${iconNames.length} icons...`"
class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5" class="w-full px-4 py-3 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 placeholder-gray-400 dark:placeholder-zinc-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<MaterialDesignIcon
icon-name="magnify"
class="absolute right-3 top-1/2 -translate-y-1/2 size-5 text-gray-400 dark:text-zinc-500 pointer-events-none"
/> />
</div> </div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700"> <div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-h-[500px] overflow-y-auto p-1"
>
<div <div
v-for="mdiIconName of searchedIconNames" v-for="mdiIconName of searchedIconNames"
:key="mdiIconName" :key="mdiIconName"
class="flex space-x-2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700" class="flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-zinc-800 hover:border-blue-500 dark:hover:border-blue-500"
:class="
iconName === mdiIconName
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-zinc-700'
"
@click="onIconClick(mdiIconName)" @click="onIconClick(mdiIconName)"
> >
<div class="my-auto">
<LxmfUserIcon <LxmfUserIcon
:icon-name="mdiIconName" :icon-name="mdiIconName"
:icon-foreground-colour="iconForegroundColour" :icon-foreground-colour="iconForegroundColour"
:icon-background-colour="iconBackgroundColour" :icon-background-colour="iconBackgroundColour"
icon-class="size-8"
/> />
<div
class="mt-2 text-xs text-center text-gray-600 dark:text-zinc-400 truncate w-full"
:title="mdiIconName"
>
{{ mdiIconName }}
</div> </div>
<div class="my-auto">{{ mdiIconName }}</div>
</div> </div>
<div v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500"> </div>
<div
v-if="searchedIconNames.length === 0"
class="text-center py-8 text-sm text-gray-500 dark:text-zinc-400"
>
No icons match your search. No icons match your search.
</div> </div>
<div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500"> <div
A maximum of {{ maxSearchResults }} icons are shown. v-if="searchedIconNames.length === maxSearchResults"
class="text-center py-2 text-xs text-gray-500 dark:text-zinc-400"
>
Showing first {{ maxSearchResults }} results. Refine your search to see more.
</div> </div>
</div> </div>
</div> </div>
<!-- Remove Icon Section -->
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove Icon</h3>
</div>
<div class="p-4">
<p class="text-sm text-gray-600 dark:text-zinc-400 mb-4">
Remove your profile icon. Anyone who has already received it will continue to see it until
you send them a new icon.
</p>
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-red-300 dark:border-red-800 bg-white dark:bg-zinc-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
@click="removeProfileIcon"
>
<MaterialDesignIcon icon-name="delete-outline" class="size-4" />
Remove Icon
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -105,115 +204,196 @@
<script> <script>
import * as mdi from "@mdi/js"; import * as mdi from "@mdi/js";
import LxmfUserIcon from "../LxmfUserIcon.vue"; import LxmfUserIcon from "../LxmfUserIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils"; import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue"; import ColourPickerDropdown from "../ColourPickerDropdown.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default { export default {
name: "ProfileIconPage", name: "ProfileIconPage",
components: { components: {
ColourPickerDropdown, ColourPickerDropdown,
LxmfUserIcon, LxmfUserIcon,
MaterialDesignIcon,
}, },
data() { data() {
return { return {
config: null, config: null,
iconName: null,
iconForegroundColour: null, iconForegroundColour: null,
iconBackgroundColour: null, iconBackgroundColour: null,
originalIconName: null,
originalIconForegroundColour: null,
originalIconBackgroundColour: null,
search: "", search: "",
maxSearchResults: 100, maxSearchResults: 200,
iconNames: [], iconNames: [],
isSaving: false,
autoSaveTimeout: null,
}; };
}, },
computed: { computed: {
searchedIconNames() { searchedIconNames() {
const searchLower = this.search.toLowerCase();
return this.iconNames return this.iconNames
.filter((iconName) => { .filter((iconName) => {
return iconName.includes(this.search); return iconName.toLowerCase().includes(searchLower);
}) })
.slice(0, this.maxSearchResults); .slice(0, this.maxSearchResults);
}, },
hasChanges() {
return (
this.iconName !== this.originalIconName ||
this.iconForegroundColour !== this.originalIconForegroundColour ||
this.iconBackgroundColour !== this.originalIconBackgroundColour
);
},
}, },
watch: { watch: {
config() { config() {
// update ui when config is updated if (this.config) {
this.iconName = this.config.lxmf_user_icon_name; this.iconName = this.config.lxmf_user_icon_name || null;
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280"; this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb"; this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
this.saveOriginalValues();
}
},
iconForegroundColour() {
this.debouncedAutoSave();
},
iconBackgroundColour() {
this.debouncedAutoSave();
},
iconName() {
this.debouncedAutoSave();
}, },
}, },
mounted() { mounted() {
this.getConfig(); this.getConfig();
// load icon names
this.iconNames = Object.keys(mdi).map((mdiIcon) => { this.iconNames = Object.keys(mdi).map((mdiIcon) => {
return mdiIcon return mdiIcon
.replace(/^mdi/, "") // Remove the "mdi" prefix .replace(/^mdi/, "")
.replace(/([a-z])([A-Z])/g, "$1-$2") // Add a hyphen between lowercase and uppercase letters .replace(/([a-z])([A-Z])/g, "$1-$2")
.toLowerCase(); // Convert the entire string to lowercase .toLowerCase();
}); });
}, },
beforeUnmount() {
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
}
},
methods: { methods: {
saveOriginalValues() {
this.originalIconName = this.iconName;
this.originalIconForegroundColour = this.iconForegroundColour;
this.originalIconBackgroundColour = this.iconBackgroundColour;
},
debouncedAutoSave() {
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
}
this.autoSaveTimeout = setTimeout(() => {
if (this.hasChanges && this.iconName && this.iconForegroundColour && this.iconBackgroundColour) {
this.saveChanges(true);
}
}, 1000);
},
async getConfig() { async getConfig() {
try { try {
const response = await window.axios.get("/api/v1/config"); const response = await window.axios.get("/api/v1/config");
this.config = response.data.config; this.config = response.data.config;
} catch (e) { } catch (e) {
// do nothing if failed to load config ToastUtils.error("Failed to load configuration");
console.log(e); console.error(e);
} }
}, },
async updateConfig(config) { async updateConfig(config, silent = false) {
try { try {
const response = await window.axios.patch("/api/v1/config", config); const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config; this.config = response.data.config;
this.saveOriginalValues();
if (!silent) {
ToastUtils.success("Profile icon saved successfully");
}
return true;
} catch (e) { } catch (e) {
ToastUtils.error("Failed to save config!"); if (!silent) {
console.log(e); ToastUtils.error("Failed to save profile icon");
}
console.error(e);
return false;
} }
}, },
async onIconClick(iconName) { async saveChanges(silent = false) {
// ensure foreground colour set if (!this.hasChanges) {
if (this.iconForegroundColour == null) {
DialogUtils.alert("Please select an icon colour first");
return; return;
} }
// ensure background colour set if (!this.iconForegroundColour || !this.iconBackgroundColour) {
if (this.iconBackgroundColour == null) { ToastUtils.warning("Please select both background and icon colors");
DialogUtils.alert("Please select a background colour first");
return; return;
} }
// confirm user wants to update their icon if (!this.iconName) {
if (!(await DialogUtils.confirm("Are you sure you want to set this as your profile icon?"))) { ToastUtils.warning("Please select an icon");
return; return;
} }
// save icon appearance this.isSaving = true;
await this.updateConfig({
lxmf_user_icon_name: iconName, try {
const success = await this.updateConfig(
{
lxmf_user_icon_name: this.iconName,
lxmf_user_icon_foreground_colour: this.iconForegroundColour, lxmf_user_icon_foreground_colour: this.iconForegroundColour,
lxmf_user_icon_background_colour: this.iconBackgroundColour, lxmf_user_icon_background_colour: this.iconBackgroundColour,
});
}, },
async removeProfileIcon() { silent
// confirm user wants to remove their icon );
if (
!(await DialogUtils.confirm( if (success && !silent) {
"Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon." ToastUtils.success("Profile icon saved successfully");
)) }
) { } finally {
this.isSaving = false;
}
},
resetChanges() {
if (!this.hasChanges) {
return; return;
} }
// remove profile icon this.iconName = this.originalIconName;
await this.updateConfig({ this.iconForegroundColour = this.originalIconForegroundColour;
this.iconBackgroundColour = this.originalIconBackgroundColour;
ToastUtils.info("Changes reset to saved values");
},
onIconClick(iconName) {
this.iconName = iconName;
},
async removeProfileIcon() {
this.isSaving = true;
try {
const success = await this.updateConfig({
lxmf_user_icon_name: null, lxmf_user_icon_name: null,
lxmf_user_icon_foreground_colour: null, lxmf_user_icon_foreground_colour: null,
lxmf_user_icon_background_colour: null, lxmf_user_icon_background_colour: null,
}); });
if (success) {
ToastUtils.success("Profile icon removed successfully");
}
} finally {
this.isSaving = false;
}
}, },
}, },
}; };