feat(profile): redesign ProfileIconPage with improved layout, color selection, and icon management features
This commit is contained in:
@@ -1,102 +1,201 @@
|
||||
<template>
|
||||
<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">
|
||||
<!-- info -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
||||
<!-- Header with Preview -->
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- colours -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<!-- Color Selection -->
|
||||
<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>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<!-- background colour -->
|
||||
<div class="p-2 flex space-x-2">
|
||||
<div class="flex my-auto">
|
||||
<ColourPickerDropdown v-model:colour="iconBackgroundColour" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div>
|
||||
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconBackgroundColour }}</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- icon colour -->
|
||||
<div class="p-2 flex space-x-2">
|
||||
<div class="flex my-auto">
|
||||
<ColourPickerDropdown v-model:colour="iconForegroundColour" />
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<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" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="iconBackgroundColour"
|
||||
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 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 class="flex items-center justify-between gap-4">
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search icons -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<!-- Icon Selection -->
|
||||
<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>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex p-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
: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"
|
||||
/>
|
||||
<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 class="divide-y divide-gray-300 dark:divide-zinc-700">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="`Search ${iconNames.length} icons...`"
|
||||
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
|
||||
v-for="mdiIconName of searchedIconNames"
|
||||
:key="mdiIconName"
|
||||
class="flex space-x-2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700"
|
||||
@click="onIconClick(mdiIconName)"
|
||||
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 class="my-auto">
|
||||
<div
|
||||
v-for="mdiIconName of searchedIconNames"
|
||||
:key="mdiIconName"
|
||||
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)"
|
||||
>
|
||||
<LxmfUserIcon
|
||||
:icon-name="mdiIconName"
|
||||
:icon-foreground-colour="iconForegroundColour"
|
||||
: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 v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500">
|
||||
<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.
|
||||
</div>
|
||||
<div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500">
|
||||
A maximum of {{ maxSearchResults }} icons are shown.
|
||||
<div
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -105,115 +204,196 @@
|
||||
<script>
|
||||
import * as mdi from "@mdi/js";
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "ProfileIconPage",
|
||||
components: {
|
||||
ColourPickerDropdown,
|
||||
LxmfUserIcon,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: null,
|
||||
iconName: null,
|
||||
iconForegroundColour: null,
|
||||
iconBackgroundColour: null,
|
||||
|
||||
originalIconName: null,
|
||||
originalIconForegroundColour: null,
|
||||
originalIconBackgroundColour: null,
|
||||
|
||||
search: "",
|
||||
maxSearchResults: 100,
|
||||
maxSearchResults: 200,
|
||||
iconNames: [],
|
||||
|
||||
isSaving: false,
|
||||
autoSaveTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
searchedIconNames() {
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return this.iconNames
|
||||
.filter((iconName) => {
|
||||
return iconName.includes(this.search);
|
||||
return iconName.toLowerCase().includes(searchLower);
|
||||
})
|
||||
.slice(0, this.maxSearchResults);
|
||||
},
|
||||
hasChanges() {
|
||||
return (
|
||||
this.iconName !== this.originalIconName ||
|
||||
this.iconForegroundColour !== this.originalIconForegroundColour ||
|
||||
this.iconBackgroundColour !== this.originalIconBackgroundColour
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
config() {
|
||||
// update ui when config is updated
|
||||
this.iconName = this.config.lxmf_user_icon_name;
|
||||
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
|
||||
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
|
||||
if (this.config) {
|
||||
this.iconName = this.config.lxmf_user_icon_name || null;
|
||||
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
|
||||
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
|
||||
|
||||
this.saveOriginalValues();
|
||||
}
|
||||
},
|
||||
iconForegroundColour() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
iconBackgroundColour() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
iconName() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
|
||||
// load icon names
|
||||
this.iconNames = Object.keys(mdi).map((mdiIcon) => {
|
||||
return mdiIcon
|
||||
.replace(/^mdi/, "") // Remove the "mdi" prefix
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2") // Add a hyphen between lowercase and uppercase letters
|
||||
.toLowerCase(); // Convert the entire string to lowercase
|
||||
.replace(/^mdi/, "")
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
}
|
||||
},
|
||||
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() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/config");
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to load configuration");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async updateConfig(config) {
|
||||
async updateConfig(config, silent = false) {
|
||||
try {
|
||||
const response = await window.axios.patch("/api/v1/config", config);
|
||||
this.config = response.data.config;
|
||||
this.saveOriginalValues();
|
||||
|
||||
if (!silent) {
|
||||
ToastUtils.success("Profile icon saved successfully");
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
ToastUtils.error("Failed to save config!");
|
||||
console.log(e);
|
||||
if (!silent) {
|
||||
ToastUtils.error("Failed to save profile icon");
|
||||
}
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async onIconClick(iconName) {
|
||||
// ensure foreground colour set
|
||||
if (this.iconForegroundColour == null) {
|
||||
DialogUtils.alert("Please select an icon colour first");
|
||||
async saveChanges(silent = false) {
|
||||
if (!this.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure background colour set
|
||||
if (this.iconBackgroundColour == null) {
|
||||
DialogUtils.alert("Please select a background colour first");
|
||||
if (!this.iconForegroundColour || !this.iconBackgroundColour) {
|
||||
ToastUtils.warning("Please select both background and icon colors");
|
||||
return;
|
||||
}
|
||||
|
||||
// confirm user wants to update their icon
|
||||
if (!(await DialogUtils.confirm("Are you sure you want to set this as your profile icon?"))) {
|
||||
if (!this.iconName) {
|
||||
ToastUtils.warning("Please select an icon");
|
||||
return;
|
||||
}
|
||||
|
||||
// save icon appearance
|
||||
await this.updateConfig({
|
||||
lxmf_user_icon_name: iconName,
|
||||
lxmf_user_icon_foreground_colour: this.iconForegroundColour,
|
||||
lxmf_user_icon_background_colour: this.iconBackgroundColour,
|
||||
});
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const success = await this.updateConfig(
|
||||
{
|
||||
lxmf_user_icon_name: this.iconName,
|
||||
lxmf_user_icon_foreground_colour: this.iconForegroundColour,
|
||||
lxmf_user_icon_background_colour: this.iconBackgroundColour,
|
||||
},
|
||||
silent
|
||||
);
|
||||
|
||||
if (success && !silent) {
|
||||
ToastUtils.success("Profile icon saved successfully");
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
resetChanges() {
|
||||
if (!this.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconName = this.originalIconName;
|
||||
this.iconForegroundColour = this.originalIconForegroundColour;
|
||||
this.iconBackgroundColour = this.originalIconBackgroundColour;
|
||||
|
||||
ToastUtils.info("Changes reset to saved values");
|
||||
},
|
||||
onIconClick(iconName) {
|
||||
this.iconName = iconName;
|
||||
},
|
||||
async removeProfileIcon() {
|
||||
// confirm user wants to remove their icon
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
"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."
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
|
||||
// remove profile icon
|
||||
await this.updateConfig({
|
||||
lxmf_user_icon_name: null,
|
||||
lxmf_user_icon_foreground_colour: null,
|
||||
lxmf_user_icon_background_colour: null,
|
||||
});
|
||||
try {
|
||||
const success = await this.updateConfig({
|
||||
lxmf_user_icon_name: null,
|
||||
lxmf_user_icon_foreground_colour: null,
|
||||
lxmf_user_icon_background_colour: null,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
ToastUtils.success("Profile icon removed successfully");
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user