1<template>
2 <FileUploader
3 :key="uploaderKey"
4 :fileTypes="['image/*']"
5 v-bind="uploadOptions"
6 @success="(file) => emit('upload', file.file_url)"
7 @failure="onUploadFailure"
8 >
9 <template #default="{ openFileSelector, uploading }">
10 <div class="flex items-center gap-4">
11 <div class="group relative flex-shrink-0 size-16">
12 <Avatar
13 class="!size-16"
14 :image="image"
15 :label="name"
16 :shape="shape"
17 />
18 <slot name="badge" />
19 <template v-if="editable">
20 <!-- one Tooltip each: it wires only its first child -->
21 <Tooltip :hoverDelay="0" side="bottom" :text="uploadTooltip">
22 <button
23 type="button"
24 class="z-1 absolute top-0 left-0 cursor-pointer !size-16"
25 :class="roundedClass"
26 :aria-label="uploadTooltip"
27 @click.stop="openFileSelector"
28 />
29 </Tooltip>
30 <Tooltip
31 v-if="image"
32 :hoverDelay="0"
33 side="bottom"
34 :text="__('Remove Photo')"
35 >
36 <button
37 type="button"
38 :aria-label="__('Remove Photo')"
39 class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-base opacity-0 duration-300 ease-in-out group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
40 @click.stop="emit('remove')"
41 >
42 <LucideX class="size-3.5 text-ink-gray-4" />
43 </button>
44 </Tooltip>
45 </template>
46 <div
47 v-if="uploading"
48 class="w-full h-full top-0 left-0 absolute bg-surface-gray-10 bg-opacity-20 flex items-center justify-center"
49 :class="roundedClass"
50 >
51 <LoadingIndicator class="size-4" />
52 </div>
53 </div>
54 <div class="flex flex-col gap-1">
55 <Skeleton
56 v-if="!name"
57 class="h-3 w-32 rounded-full motion-reduce:animate-none"
58 />
59 <div v-else-if="!editName" class="flex items-end gap-1">
60 <span class="text-md sm:text-xl !font-semibold text-ink-gray-8">
61 {{ name }}
62 </span>
63 <Button
64 v-if="editable && renameable"
65 class="!px-1 !h-5"
66 variant="ghost"
67 :aria-label="__('Edit name')"
68 @click="editFullName"
69 >
70 <EditIcon class="size-3.5" />
71 </Button>
72 </div>
73 <div v-else class="flex items-center gap-1">
74 <TextInput
75 ref="fullNameRef"
76 v-model="fullName"
77 @keydown.enter="isNameDirty ? save() : (editName = false)"
78 @keydown.esc.stop="editName = false"
79 />
80 <Button
81 variant="outline"
82 icon="lucide-check"
83 :aria-label="__('Save name')"
84 :loading="busy"
85 :disabled="busy"
86 @click="isNameDirty ? save() : (editName = false)"
87 />
88 </div>
89 <span class="text-p-sm text-ink-gray-6">{{ subtitle }}</span>
90 <slot />
91 </div>
92 </div>
93 </template>
94 </FileUploader>
95</template>
96
97<script setup lang="ts">
98import LucideX from "~icons/lucide/x";
99import EditIcon from "~icons/lucide/edit";
100import {
101 Avatar,
102 Button,
103 FileUploader,
104 LoadingIndicator,
105 Skeleton,
106 TextInput,
107 Tooltip,
108 toast,
109} from "frappe-ui";
110import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
111import { __ } from "./translation";
112
113const props = withDefaults(
114 defineProps<{
115 name?: string;
116 subtitle?: string;
117 image?: string;
118 shape?: "circle" | "square";
119 busy?: boolean;
120 editable?: boolean;
121 renameable?: boolean;
122 uploadOptions?: Record<string, unknown>;
123 }>(),
124 { shape: "circle", editable: true, renameable: true }
125);
126
127const emit = defineEmits<{
128 (e: "upload", fileUrl: string): void;
129 (e: "remove"): void;
130 (e: "rename", fullName: string): void;
131}>();
132
133const uploadTooltip = computed(() =>
134 props.image ? __("Change Photo") : __("Upload Photo")
135);
136const roundedClass = computed(() =>
137 props.shape === "square" ? "rounded-5" : "rounded-full"
138);
139
140const editName = ref(false);
141const fullName = ref("");
142const fullNameRef = useTemplateRef("fullNameRef");
143const isNameDirty = computed(
144 () => !!fullName.value.trim() && fullName.value.trim() !== props.name
145);
146
147// A saved rename comes back as a new name; a failed one stays open to retry.
148watch(
149 () => props.name,
150 () => (editName.value = false)
151);
152
153function editFullName() {
154 fullName.value = props.name ?? "";
155 editName.value = true;
156 nextTick(() => fullNameRef.value?.focus());
157}
158
159function save() {
160 emit("rename", fullName.value.trim());
161}
162
163// FileUploader keeps the failed file in its input, so picking it again fires no change. Remount for a fresh input.
164const uploaderKey = ref(0);
165function onUploadFailure() {
166 toast.error(__("Could not upload the image"));
167 uploaderKey.value++;
168}
169</script>
170