✨ Added function to customize the user avatar
This commit is contained in:
@@ -29,6 +29,7 @@ from classquiz.routers import (
|
||||
sitemap,
|
||||
remote,
|
||||
community,
|
||||
avatar,
|
||||
)
|
||||
from classquiz.socket_server import sio
|
||||
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
|
||||
@@ -100,5 +101,6 @@ app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", incl
|
||||
app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True)
|
||||
app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True)
|
||||
app.include_router(community.router, tags=["community"], prefix="/api/v1/community", include_in_schema=True)
|
||||
app.include_router(avatar.router, tags=["avatar"], prefix="/api/v1/avatar", include_in_schema=True)
|
||||
|
||||
app.mount("/", ASGIApp(sio))
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
import gzip
|
||||
|
||||
from fastapi import APIRouter, Response, HTTPException, Depends
|
||||
import py_avataaars_no_png as av
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from classquiz.config import redis
|
||||
|
||||
from classquiz.auth import get_current_user
|
||||
from classquiz.db.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AvatarItemsAsList:
|
||||
skin_color = list(av.SkinColor)
|
||||
hair_color = list(av.HairColor)
|
||||
facial_hair_type = list(av.FacialHairType)
|
||||
facial_hair_color = list(av.HairColor)
|
||||
top_type = list(av.TopType)
|
||||
hat_color = list(av.Color)
|
||||
mouth_type = list(av.MouthType)
|
||||
eyebrow_type = list(av.EyebrowType)
|
||||
nose_type = list(av.NoseType)
|
||||
accessories_type = list(av.AccessoriesType)
|
||||
clothe_type = list(av.ClotheType)
|
||||
clothe_color = list(av.Color)
|
||||
clothe_graphic_type = list(av.ClotheGraphicType)
|
||||
|
||||
|
||||
@router.get("/custom", response_class=PlainTextResponse)
|
||||
async def get_customized_avatar(
|
||||
resp: Response,
|
||||
skin_color: int | None = 0,
|
||||
hair_color: int | None = 0,
|
||||
facial_hair_type: int | None = 0,
|
||||
facial_hair_color: int | None = 0,
|
||||
top_type: int | None = 0,
|
||||
hat_color: int | None = 0,
|
||||
mouth_type: int | None = 0,
|
||||
eyebrow_type: int | None = 0,
|
||||
nose_type: int | None = 0,
|
||||
accessories_type: int | None = 0,
|
||||
clothe_type: int | None = 0,
|
||||
clothe_color: int | None = 0,
|
||||
clothe_graphic_type: int | None = 0,
|
||||
):
|
||||
try:
|
||||
skin_color = AvatarItemsAsList.skin_color[skin_color]
|
||||
hair_color = AvatarItemsAsList.hat_color[hair_color]
|
||||
facial_hair_type = AvatarItemsAsList.facial_hair_type[facial_hair_type]
|
||||
facial_hair_color = AvatarItemsAsList.facial_hair_color[facial_hair_color]
|
||||
top_type = AvatarItemsAsList.top_type[top_type]
|
||||
hat_color = AvatarItemsAsList.hat_color[hat_color]
|
||||
mouth_type = AvatarItemsAsList.mouth_type[mouth_type]
|
||||
eyebrow_type = AvatarItemsAsList.eyebrow_type[eyebrow_type]
|
||||
nose_type = AvatarItemsAsList.nose_type[nose_type]
|
||||
accessories_type = AvatarItemsAsList.accessories_type[accessories_type]
|
||||
clothe_type = AvatarItemsAsList.clothe_type[clothe_type]
|
||||
clothe_color = AvatarItemsAsList.clothe_color[clothe_color]
|
||||
clothe_graphic_type = AvatarItemsAsList.clothe_graphic_type[clothe_graphic_type]
|
||||
except IndexError:
|
||||
raise HTTPException(status_code=400, detail="One parameter-value doesn't exist.")
|
||||
resp.headers.append("Content-Type", "image/svg+xml")
|
||||
avatar = av.PyAvataaar(
|
||||
style=av.AvatarStyle.TRANSPARENT,
|
||||
skin_color=skin_color,
|
||||
hair_color=hair_color,
|
||||
facial_hair_type=facial_hair_type,
|
||||
facial_hair_color=facial_hair_color,
|
||||
top_type=top_type,
|
||||
hat_color=hat_color,
|
||||
mouth_type=mouth_type,
|
||||
eyebrow_type=eyebrow_type,
|
||||
nose_type=nose_type,
|
||||
accessories_type=accessories_type,
|
||||
clothe_type=clothe_type,
|
||||
clothe_color=clothe_color,
|
||||
clothe_graphic_type=clothe_graphic_type,
|
||||
).render_svg()
|
||||
# print(f"skin_color: {len(AvatarItemsAsList.skin_color)},")
|
||||
# print(f"hair_color: {len(AvatarItemsAsList.hair_color)},")
|
||||
# print(f"facial_hair_type: {len(AvatarItemsAsList.facial_hair_type)},")
|
||||
# print(f"facial_hair_color: {len(AvatarItemsAsList.facial_hair_color)},")
|
||||
# print(f"top_type: {len(AvatarItemsAsList.top_type)},")
|
||||
# print(f"hat_color: {len(AvatarItemsAsList.hat_color)},")
|
||||
# print(f"mouth_type: {len(AvatarItemsAsList.mouth_type)},")
|
||||
# print(f"eyebrow_type: {len(AvatarItemsAsList.eyebrow_type)},")
|
||||
# print(f"nose_type: {len(AvatarItemsAsList.nose_type)},")
|
||||
# print(f"accessories_type: {len(AvatarItemsAsList.accessories_type)},")
|
||||
# print(f"clothe_type: {len(AvatarItemsAsList.clothe_type)},")
|
||||
# print(f"clothe_color: {len(AvatarItemsAsList.clothe_color)},")
|
||||
# print(f"clothe_graphic_type: {len(AvatarItemsAsList.clothe_graphic_type)},")
|
||||
return avatar
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_avatar(
|
||||
skin_color: int | None = 0,
|
||||
hair_color: int | None = 0,
|
||||
facial_hair_type: int | None = 0,
|
||||
facial_hair_color: int | None = 0,
|
||||
top_type: int | None = 0,
|
||||
hat_color: int | None = 0,
|
||||
mouth_type: int | None = 0,
|
||||
eyebrow_type: int | None = 0,
|
||||
nose_type: int | None = 0,
|
||||
accessories_type: int | None = 0,
|
||||
clothe_type: int | None = 0,
|
||||
clothe_color: int | None = 0,
|
||||
clothe_graphic_type: int | None = 0,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
skin_color = AvatarItemsAsList.skin_color[skin_color]
|
||||
hair_color = AvatarItemsAsList.hat_color[hair_color]
|
||||
facial_hair_type = AvatarItemsAsList.facial_hair_type[facial_hair_type]
|
||||
facial_hair_color = AvatarItemsAsList.facial_hair_color[facial_hair_color]
|
||||
top_type = AvatarItemsAsList.top_type[top_type]
|
||||
hat_color = AvatarItemsAsList.hat_color[hat_color]
|
||||
mouth_type = AvatarItemsAsList.mouth_type[mouth_type]
|
||||
eyebrow_type = AvatarItemsAsList.eyebrow_type[eyebrow_type]
|
||||
nose_type = AvatarItemsAsList.nose_type[nose_type]
|
||||
accessories_type = AvatarItemsAsList.accessories_type[accessories_type]
|
||||
clothe_type = AvatarItemsAsList.clothe_type[clothe_type]
|
||||
clothe_color = AvatarItemsAsList.clothe_color[clothe_color]
|
||||
clothe_graphic_type = AvatarItemsAsList.clothe_graphic_type[clothe_graphic_type]
|
||||
except IndexError:
|
||||
raise HTTPException(status_code=400, detail="One parameter-value doesn't exist.")
|
||||
avatar = av.PyAvataaar(
|
||||
style=av.AvatarStyle.TRANSPARENT,
|
||||
skin_color=skin_color,
|
||||
hair_color=hair_color,
|
||||
facial_hair_type=facial_hair_type,
|
||||
facial_hair_color=facial_hair_color,
|
||||
top_type=top_type,
|
||||
hat_color=hat_color,
|
||||
mouth_type=mouth_type,
|
||||
eyebrow_type=eyebrow_type,
|
||||
nose_type=nose_type,
|
||||
accessories_type=accessories_type,
|
||||
clothe_type=clothe_type,
|
||||
clothe_color=clothe_color,
|
||||
clothe_graphic_type=clothe_graphic_type,
|
||||
).render_svg()
|
||||
user.avatar = gzip.compress(str.encode(avatar))
|
||||
await redis.delete(user.email)
|
||||
await user.update()
|
||||
Generated
-14
@@ -1007,13 +1007,6 @@ packages:
|
||||
engines: { node: '>=6.0.0' }
|
||||
dev: true
|
||||
|
||||
/@neodrag/svelte/1.2.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-oGOxAgjtLbHyCJIWv35cvuHpgip9Kzn0ANrCidZb3rXV29w3AWhS7VtKAQfRinzFbmfK0Jeic7aSYdGmtKFKqw==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/@nodelib/fs.scandir/2.1.5:
|
||||
resolution:
|
||||
{
|
||||
@@ -4929,13 +4922,6 @@ packages:
|
||||
sander: 0.5.1
|
||||
dev: true
|
||||
|
||||
/sortablejs/1.15.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/source-map-js/1.0.2:
|
||||
resolution:
|
||||
{
|
||||
|
||||
@@ -236,5 +236,21 @@
|
||||
},
|
||||
"uploader": {
|
||||
"add_image": "Add image"
|
||||
},
|
||||
"avatar_settings": {
|
||||
"skin_color": "Skin color",
|
||||
"top_type": "Top",
|
||||
"hair_color": "Hair color",
|
||||
"facial_hair_type": "Facial hair",
|
||||
"facial_hair_color": "Facial hair color",
|
||||
"mouth_type": "Mouth",
|
||||
"eyebrow_type": "Eyebrow",
|
||||
"accessories_type": "Glasses",
|
||||
"hat_color": "Hat color",
|
||||
"clothe_type": "Clothes",
|
||||
"clothe_color": "Clothing color",
|
||||
"clothe_graphic_type": "Graphic",
|
||||
"thats_you": "That's You!",
|
||||
"start_over": "Start over"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,16 @@
|
||||
<Spinner />
|
||||
{:then user}
|
||||
<div class="w-full grid grid-cols-6">
|
||||
<img
|
||||
class="rounded-md md:w-80"
|
||||
src="/api/v1/users/avatar"
|
||||
alt="Profile image of {user.username}"
|
||||
/>
|
||||
<div>
|
||||
<img
|
||||
class="rounded-md md:w-80"
|
||||
src="/api/v1/users/avatar"
|
||||
alt="Profile image of {user.username}"
|
||||
/>
|
||||
<div class="m-2 flex justify-center">
|
||||
<BrownButton href="/account/settings/avatar">Change avatar</BrownButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-rows-2 col-start-2 col-end-7">
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { bounceOut } from 'svelte/easing';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
const item_count = {
|
||||
skin_color: 7,
|
||||
top_type: 35,
|
||||
hair_color: 10,
|
||||
facial_hair_type: 6,
|
||||
facial_hair_color: 10,
|
||||
mouth_type: 12,
|
||||
eyebrow_type: 13,
|
||||
accessories_type: 7,
|
||||
hat_color: 15,
|
||||
clothe_type: 9,
|
||||
clothe_color: 15,
|
||||
clothe_graphic_type: 11
|
||||
};
|
||||
|
||||
const translation_map = {
|
||||
skin_color: $t('avatar_settings.skin_color'),
|
||||
top_type: $t('avatar_settings.top_type'),
|
||||
hair_color: $t('avatar_settings.hair_color'),
|
||||
facial_hair_type: $t('avatar_settings.facial_hair_type'),
|
||||
facial_hair_color: $t('avatar_settings.facial_hair_color'),
|
||||
mouth_type: $t('avatar_settings.mouth_type'),
|
||||
eyebrow_type: $t('avatar_settings.eyebrow_type'),
|
||||
accessories_type: $t('avatar_settings.accessories_type'),
|
||||
hat_color: $t('avatar_settings.hat_color'),
|
||||
clothe_type: $t('avatar_settings.clothe_type'),
|
||||
clothe_color: $t('avatar_settings.clothe_color'),
|
||||
clothe_graphic_type: $t('avatar_settings.clothe_graphic_type')
|
||||
};
|
||||
|
||||
let data = {
|
||||
skin_color: 0,
|
||||
top_type: 0,
|
||||
hair_color: 0,
|
||||
facial_hair_type: 0,
|
||||
facial_hair_color: 0,
|
||||
mouth_type: 0,
|
||||
eyebrow_type: 0,
|
||||
accessories_type: 0,
|
||||
hat_color: 0,
|
||||
clothe_type: 0,
|
||||
clothe_color: 0,
|
||||
clothe_graphic_type: 0
|
||||
};
|
||||
|
||||
const data_keys = Object.keys(data);
|
||||
let index = 0;
|
||||
// let index = 10;
|
||||
let save_finished: undefined | boolean = undefined;
|
||||
let finished = false;
|
||||
|
||||
let image_url;
|
||||
|
||||
$: console.log('index', index);
|
||||
const get_image_url = (input_data) => {
|
||||
return `/api/v1/avatar/custom?${new URLSearchParams(input_data).toString()}`;
|
||||
};
|
||||
$: image_url = get_image_url(data);
|
||||
|
||||
const save_avatar = async () => {
|
||||
save_finished = false;
|
||||
const res = await fetch(`/api/v1/avatar/save?${new URLSearchParams(data).toString()}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (res.ok) {
|
||||
save_finished = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="h-full">
|
||||
<div class="grid grid-cols-6 overflow-hidden h-full">
|
||||
<div class="border-r-4 border-black h-full">
|
||||
<img src={image_url} />
|
||||
</div>
|
||||
<div class="col-start-2 col-end-7 overflow-scroll">
|
||||
<div class="flex pl-2">
|
||||
<div class="mr-auto">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
index = index - 1;
|
||||
}}
|
||||
disabled={index < 1}>Back</BrownButton
|
||||
>
|
||||
</div>
|
||||
<div class="mx-auto">
|
||||
<h2 class="text-2xl">
|
||||
{translation_map[data_keys[index]]} ({index + 1}/{data_keys.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<BrownButton disabled={index < 11}>Finish</BrownButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4">
|
||||
{#each Array.from(Array(item_count[data_keys[index]]).keys()) as key}
|
||||
<button
|
||||
class="hover:opacity-80 transition-all"
|
||||
on:click={() => {
|
||||
data[data_keys[index]] = key;
|
||||
if (index < 11) {
|
||||
index++;
|
||||
} else {
|
||||
finished = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={get_image_url({ ...data, [data_keys[index]]: key })}
|
||||
in:fade={{ duration: 100 }}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if finished}
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full z-30 bg-black flex justify-center flex-col bg-opacity-90"
|
||||
out:fade={{ duration: 200 }}
|
||||
in:fade={{ duration: 300 }}
|
||||
>
|
||||
<h1 class="m-auto text-4xl" in:fade={{ delay: 3500 }}>{$t('avatar_settings.thats_you')}</h1>
|
||||
<img
|
||||
class="m-auto w-1/2 h-1/2 z-20"
|
||||
src={get_image_url(data)}
|
||||
in:fly={{ delay: 500, duration: 4000, y: -500, easing: bounceOut }}
|
||||
/>
|
||||
<div class="m-auto grid grid-cols-2 gap-4" in:fade={{ delay: 3500 }}>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
index = 0;
|
||||
finished = false;
|
||||
}}>{$t('avatar_settings.start_over')}</BrownButton
|
||||
>
|
||||
<BrownButton on:click={save_avatar} flex={true} disabled={save_finished === true}>
|
||||
{#if save_finished === undefined}{$t('words.save')}
|
||||
{:else if save_finished === true}
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if save_finished === false}
|
||||
<Spinner my_20={false} />
|
||||
{/if}
|
||||
</BrownButton>
|
||||
<BrownButton href="/account/settings">Go back</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
finished = false;
|
||||
}}>Close</BrownButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user