feat: Add more options for avatar uploads (gravatar, URL)

This commit is contained in:
Jesse Wierzbinski 2024-12-16 17:25:52 +01:00
parent 2352bec77b
commit 8b43f7b2c7
No known key found for this signature in database
9 changed files with 310 additions and 9 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -16,18 +16,14 @@
</FormItem>
</FormField>
<FormField v-slot="{ handleChange, handleBlur }" name="avatar">
<FormItem>
<FormField v-slot="{ setValue }" name="avatar">
<FormItem class="grid gap-1">
<FormLabel>
{{ m.safe_icy_bulldog_quell() }}
</FormLabel>
<FormControl>
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur" />
<ImageUploader v-model:image="identity.account.avatar" @submit-file="file => setValue(file)" @submit-url="url => setValue(url)" />
</FormControl>
<FormDescription>
{{ m.aware_quiet_opossum_catch() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@ -183,6 +179,7 @@ import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea";
import * as m from "~/paraglide/messages.js";
import ImageUploader from "./image-uploader.vue";
if (!identity.value) {
throw new Error("Identity not found.");
@ -217,6 +214,7 @@ const formSchema = toTypedSchema(
.avatar_size_limit,
}),
)
.or(z.string().url())
.optional(),
name: z
.string()
@ -326,4 +324,4 @@ defineExpose({
submitForm: () => handleSubmit(),
dirty: computed(() => form.meta.value.dirty),
});
</script>
</script>

View file

@ -0,0 +1,201 @@
<template>
<Dialog v-model:open="open">
<DialogTrigger :as-child="true">
<Button v-bind="$attrs" variant="ghost" class="h-fit w-fit p-0 m-0 relative group border overflow-hidden">
<Avatar size="lg" :src="image" :name="displayName" />
<div
class="absolute inset-0 bg-black/80 flex group-hover:opacity-100 opacity-0 duration-200 items-center justify-center">
<Upload />
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
Change image
</DialogTitle>
<DialogDescription class="sr-only">
Upload an image, add a Gravatar email or use a URL.
</DialogDescription>
<form class="p-4 grid gap-6" @submit="submit">
<Tabs default-value="upload" class="mt-2 data-[component=tabpanel]:*:mt-6">
<TabsList class="w-full *:w-full">
<TabsTrigger value="upload">
Upload
</TabsTrigger>
<TabsTrigger value="gravatar">
Gravatar
</TabsTrigger>
<TabsTrigger value="url">
URL
</TabsTrigger>
</TabsList>
<TabsContent value="upload">
<FormField v-slot="{ handleChange, handleBlur }" name="image">
<FormItem>
<FormLabel class="sr-only">
Upload
</FormLabel>
<FormControl>
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur"
:disabled="isSubmitting" />
</FormControl>
<FormDescription>
{{ m.lime_late_millipede_urge() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</TabsContent>
<TabsContent value="gravatar">
<FormField v-slot="{ componentField, errors, value }" name="email" @update:model-value="async (value) => {
gravatarUrl = await emailToGravatar(value)
}">
<FormItem>
<FormLabel>
Gravatar email
</FormLabel>
<FormControl>
<Input v-bind="componentField" :disabled="isSubmitting"
placeholder="peter.griffin@fox.com" />
</FormControl>
<FormMessage />
<div v-if="value" class="grid gap-4 !mt-4">
<Label>Preview</Label>
<Avatar size="lg" :src="gravatarUrl" />
</div>
</FormItem>
</FormField>
</TabsContent>
<TabsContent value="url">
<FormField v-slot="{ componentField, errors, value }" name="url">
<FormItem>
<FormLabel>
URL
</FormLabel>
<FormControl>
<Input v-bind="componentField" :disabled="isSubmitting"
placeholder="https://mysite.com/avar.webp" />
</FormControl>
<FormMessage />
<div v-if="value" class="grid gap-4 !mt-4">
<Label>Preview</Label>
<Avatar size="lg" :src="value" />
</div>
</FormItem>
</FormField>
</TabsContent>
</Tabs>
<DialogFooter>
<DialogClose :as-child="true">
<Button variant="outline" :disabled="isSubmitting">
{{ m.soft_bold_ant_attend() }}
</Button>
</DialogClose>
<Button type="submit" variant="default" :disabled="isSubmitting">
{{ m.teary_antsy_panda_aid() }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { Upload } from "lucide-vue-next";
import { useForm } from "vee-validate";
import { z } from "zod";
import Avatar from "~/components/profiles/avatar.vue";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "~/components/ui/dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import * as m from "~/paraglide/messages.js";
const { maxSize } = defineProps<{
displayName?: string;
maxSize?: number;
}>();
const image = defineModel<string>("image", {
required: true,
});
const emit = defineEmits<{
submitFile: [file: File];
submitUrl: [url: string];
}>();
const schema = toTypedSchema(
z
.object({
image: z
.instanceof(File, {
message: m.sound_topical_gopher_offer(),
})
.refine(
(v) => v.size <= (maxSize ?? Number.MAX_SAFE_INTEGER),
m.zippy_caring_raven_edit({
size: maxSize ?? Number.MAX_SAFE_INTEGER,
}),
),
})
.or(
z.object({
url: z.string().url(),
}),
)
.or(
z.object({
email: z.string().email(),
}),
),
);
const emailToGravatar = async (email: string) => {
const sha256 = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(email),
);
return `https://www.gravatar.com/avatar/${Array.from(new Uint8Array(sha256))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")}?size=512`;
};
const open = ref(false);
const gravatarUrl = ref<string | undefined>(undefined);
const { handleSubmit, isSubmitting, values } = useForm({
validationSchema: schema,
});
const submit = handleSubmit(async (values) => {
if ((values as { image: File }).image) {
emit("submitFile", (values as { image: File }).image);
} else if ((values as { url: string }).url) {
emit("submitUrl", (values as { url: string }).url);
} else if ((values as { email: string }).email) {
emit(
"submitUrl",
await emailToGravatar((values as { email: string }).email),
);
}
open.value = false;
});
</script>

View file

@ -31,4 +31,4 @@ const getInitials = (name: string): string => {
};
const shape = useSetting(SettingIds.AvatarShape);
</script>
</script>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TabsRootEmits, TabsRootProps } from "radix-vue";
import { TabsRoot, useForwardPropsEmits } from "radix-vue";
const props = defineProps<TabsRootProps>();
const emits = defineEmits<TabsRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<TabsRoot v-bind="forwarded">
<slot />
</TabsRoot>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { TabsContent, type TabsContentProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
TabsContentProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TabsContent
:class="cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)"
data-component="tabpanel"
v-bind="delegatedProps"
>
<slot />
</TabsContent>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { TabsList, type TabsListProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
TabsListProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TabsList
v-bind="delegatedProps"
:class="cn(
'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
props.class,
)"
>
<slot />
</TabsList>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { TabsTrigger, type TabsTriggerProps, useForwardProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
TabsTriggerProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<TabsTrigger
v-bind="forwardedProps"
:class="cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
props.class,
)"
>
<span class="truncate">
<slot />
</span>
</TabsTrigger>
</template>

View file

@ -0,0 +1,4 @@
export { default as Tabs } from "./Tabs.vue";
export { default as TabsContent } from "./TabsContent.vue";
export { default as TabsList } from "./TabsList.vue";
export { default as TabsTrigger } from "./TabsTrigger.vue";