Create API for adding an avatar to a user

This commit is contained in:
Eli Ribble 2026-03-28 18:55:13 -07:00
parent da7549eeda
commit ad90f9c95e
No known key found for this signature in database
7 changed files with 131 additions and 24 deletions

26
api/avatar.go Normal file
View file

@ -0,0 +1,26 @@
package api
import (
"context"
"fmt"
"net/http"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
)
func avatarPost(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
if len(uploads) == 0 {
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "No upload found")
}
if len(uploads) != 1 {
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "You must only submit one file at a time")
}
upload := uploads[0]
err := platform.AvatarCreate(r.Context(), u, upload)
if err != nil {
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "Create avatar: %w", err)
}
return fmt.Sprintf("/avatar/%s", upload.UUID.String()), nil
}

View file

@ -16,6 +16,7 @@ func AddRoutes(r chi.Router) {
// Authenticated endpoints // Authenticated endpoints
r.Method("POST", "/audio/{uuid}", auth.NewEnsureAuth(apiAudioPost)) r.Method("POST", "/audio/{uuid}", auth.NewEnsureAuth(apiAudioPost))
r.Method("POST", "/audio/{uuid}/content", auth.NewEnsureAuth(apiAudioContentPost)) r.Method("POST", "/audio/{uuid}/content", auth.NewEnsureAuth(apiAudioContentPost))
r.Method("POST", "/avatar", authenticatedHandlerPostMultipart(avatarPost, file.CollectionAvatar))
r.Method("GET", "/client/ios", auth.NewEnsureAuth(handleClientIos)) r.Method("GET", "/client/ios", auth.NewEnsureAuth(handleClientIos))
r.Method("GET", "/communication", authenticatedHandlerJSON(listCommunication)) r.Method("GET", "/communication", authenticatedHandlerJSON(listCommunication))
r.Method("POST", "/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis)) r.Method("POST", "/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis))

View file

@ -11,6 +11,7 @@ import (
) )
type contentURLAPI struct { type contentURLAPI struct {
Avatar string `json:"avatar"`
Communication string `json:"communication"` Communication string `json:"communication"`
PublicreportMessage string `json:"publicreport_message"` PublicreportMessage string `json:"publicreport_message"`
ReviewTask string `json:"review_task"` ReviewTask string `json:"review_task"`
@ -44,6 +45,7 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query
Self: user, Self: user,
URLs: contentURLs{ URLs: contentURLs{
API: contentURLAPI{ API: contentURLAPI{
Avatar: config.MakeURLNidus("/api/avatar"),
Communication: urls.API.Communication, Communication: urls.API.Communication,
PublicreportMessage: urls.API.Publicreport.Message, PublicreportMessage: urls.API.Publicreport.Message,
ReviewTask: config.MakeURLNidus("/api/review-task"), ReviewTask: config.MakeURLNidus("/api/review-task"),

40
platform/avatar.go Normal file
View file

@ -0,0 +1,40 @@
package platform
import (
"bytes"
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
"github.com/disintegration/imaging"
"github.com/rs/zerolog/log"
)
func AvatarCreate(ctx context.Context, u User, upload file.Upload) error {
f, err := file.NewFileReader(file.CollectionAvatar, upload.UUID)
// Decode image (supports PNG, JPG, GIF)
img, err := imaging.Decode(f)
if err != nil {
return fmt.Errorf("decode: %w", err)
}
// Resize to 200x200, maintaining aspect ratio
avatar := imaging.Fill(img, 200, 200, imaging.Center, imaging.Lanczos)
// Save or encode
//filename := fmt.Sprintf("avatar-%s.jpg", upload.UUID.String())
//err = imaging.Save(avatar, filename)
//log.Info().Str("filename", filename).Msg("wrote avatar file")
// Or encode to buffer: imaging.Encode(writer, avatar, imaging.JPEG)
writer := &bytes.Buffer{}
err = imaging.Encode(writer, avatar, imaging.PNG)
if err != nil {
return fmt.Errorf("encode: %w", err)
}
err = file.FileContentWrite(writer, file.CollectionAvatar, upload.UUID)
if err != nil {
return fmt.Errorf("write content: %w", err)
}
log.Info().Str("uuid", upload.UUID.String()).Msg("wrote avatar file")
return nil
}

View file

@ -19,6 +19,7 @@ var collectionToExtension map[Collection]string = map[Collection]string{
CollectionAudioNormalized: "ogg", CollectionAudioNormalized: "ogg",
CollectionAudioRaw: "raw", CollectionAudioRaw: "raw",
CollectionAudioTranscoded: "ogg", CollectionAudioTranscoded: "ogg",
CollectionAvatar: "png",
CollectionCSV: "csv", CollectionCSV: "csv",
CollectionLogo: "png", CollectionLogo: "png",
CollectionPublicImage: "img", CollectionPublicImage: "img",
@ -28,6 +29,7 @@ var collectionToSubdir map[Collection]string = map[Collection]string{
CollectionAudioNormalized: "audio-normalized", CollectionAudioNormalized: "audio-normalized",
CollectionAudioRaw: "audio-raw", CollectionAudioRaw: "audio-raw",
CollectionAudioTranscoded: "audio-transcoded", CollectionAudioTranscoded: "audio-transcoded",
CollectionAvatar: "avatar",
CollectionCSV: "csv", CollectionCSV: "csv",
CollectionLogo: "logo", CollectionLogo: "logo",
CollectionPublicImage: "public-image", CollectionPublicImage: "public-image",

View file

@ -6,6 +6,7 @@ const (
CollectionAudioRaw Collection = iota CollectionAudioRaw Collection = iota
CollectionAudioNormalized CollectionAudioNormalized
CollectionAudioTranscoded CollectionAudioTranscoded
CollectionAvatar
CollectionCSV CollectionCSV
CollectionImageRaw CollectionImageRaw
CollectionLogo CollectionLogo

View file

@ -40,7 +40,7 @@ pre {
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h4 class="mb-0">User Configuration</h4> <h4 class="mb-0">User Configuration</h4>
</div> </div>
<div v-if="user" class="card-body"> <div v-if="user_" class="card-body">
<!-- Avatar Section --> <!-- Avatar Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-12"> <div class="col-md-12">
@ -48,7 +48,7 @@ pre {
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="position-relative"> <div class="position-relative">
<img <img
:src="user.avatar || defaultAvatar" :src="avatar || defaultAvatar"
alt="User Avatar" alt="User Avatar"
class="rounded-circle avatar-preview" class="rounded-circle avatar-preview"
/> />
@ -88,7 +88,7 @@ pre {
</label> </label>
<input <input
id="displayName" id="displayName"
v-model="user.display_name" v-model="user_.display_name"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Enter display name" placeholder="Enter display name"
@ -102,7 +102,7 @@ pre {
</label> </label>
<input <input
id="username" id="username"
v-model="user.username" v-model="user_.username"
type="text" type="text"
class="form-control" class="form-control"
readonly readonly
@ -117,7 +117,7 @@ pre {
<label for="userRole" class="form-label fw-bold"> <label for="userRole" class="form-label fw-bold">
User Role User Role
</label> </label>
<select id="userRole" v-model="user.role" class="form-select"> <select id="userRole" v-model="user_.role" class="form-select">
<option value="">Select a role</option> <option value="">Select a role</option>
<option <option
v-for="role in availableRoles" v-for="role in availableRoles"
@ -137,7 +137,7 @@ pre {
type="radio" type="radio"
class="btn-check" class="btn-check"
id="statusActive" id="statusActive"
v-model="user.status" v-model="user_.status"
value="active" value="active"
/> />
<label class="btn btn-outline-success" for="statusActive"> <label class="btn btn-outline-success" for="statusActive">
@ -148,7 +148,7 @@ pre {
type="radio" type="radio"
class="btn-check" class="btn-check"
id="statusInactive" id="statusInactive"
v-model="user.status" v-model="user_.status"
value="inactive" value="inactive"
/> />
<label class="btn btn-outline-secondary" for="statusInactive"> <label class="btn btn-outline-secondary" for="statusInactive">
@ -164,7 +164,7 @@ pre {
<label class="form-label fw-bold">User Tags</label> <label class="form-label fw-bold">User Tags</label>
<div class="mb-2"> <div class="mb-2">
<span <span
v-for="tag in user.tags" v-for="tag in user_.tags"
:key="tag" :key="tag"
class="badge bg-info text-dark me-2 mb-2" class="badge bg-info text-dark me-2 mb-2"
> >
@ -177,7 +177,7 @@ pre {
></button> ></button>
</span> </span>
<span <span
v-if="user.tags.length === 0" v-if="user_.tags.length === 0"
class="text-muted fst-italic" class="text-muted fst-italic"
> >
No tags added No tags added
@ -190,7 +190,7 @@ pre {
v-for="tag in availableTags" v-for="tag in availableTags"
:key="tag" :key="tag"
:value="tag" :value="tag"
:disabled="user.tags.includes(tag)" :disabled="user_.tags.includes(tag)"
> >
{{ tag }} {{ tag }}
</option> </option>
@ -199,7 +199,7 @@ pre {
class="btn btn-outline-primary" class="btn btn-outline-primary"
type="button" type="button"
@click="addTag" @click="addTag"
:disabled="!selectedTag || user.tags.includes(selectedTag)" :disabled="!selectedTag || user_.tags.includes(selectedTag)"
> >
<i class="bi bi-plus-lg"></i> Add Tag <i class="bi bi-plus-lg"></i> Add Tag
</button> </button>
@ -241,6 +241,7 @@ pre {
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, onMounted, ref, reactive } from "vue"; import { computed, defineComponent, onMounted, ref, reactive } from "vue";
import { useUserStore } from "@/store/user";
import { useUsersStore } from "@/store/users"; import { useUsersStore } from "@/store/users";
interface User { interface User {
@ -260,12 +261,14 @@ interface Role {
interface Props { interface Props {
id: int; id: int;
} }
const avatar = ref<string>("");
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const props = defineProps<Props>(); const props = defineProps<Props>();
const selectedFile = ref<File | null>(null);
const selectedTag = ref<string>(""); const selectedTag = ref<string>("");
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const user = ref<User | null>(null); const user = useUserStore();
const user_ = ref<User | null>(null);
const defaultAvatar = const defaultAvatar =
"https://via.placeholder.com/150/cccccc/666666?text=No+Avatar"; "https://via.placeholder.com/150/cccccc/666666?text=No+Avatar";
@ -295,39 +298,71 @@ const handleAvatarChange = (event: Event) => {
const file = target.files?.[0]; const file = target.files?.[0];
if (file) { if (file) {
selectedFile.value = file;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
user.avatar = e.target?.result as string; avatar.value = e.target?.result as string;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}; };
const removeAvatar = () => { const removeAvatar = () => {
user.avatar = ""; avatar = "";
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = ""; fileInput.value.value = "";
} }
}; };
const addTag = () => { const addTag = () => {
if (selectedTag.value && !user.tags.includes(selectedTag.value)) { if (selectedTag.value && !user_.value.tags.includes(selectedTag.value)) {
user.tags.push(selectedTag.value); user_.value.tags.push(selectedTag.value);
selectedTag.value = ""; selectedTag.value = "";
} }
}; };
const removeTag = (tag: string) => { const removeTag = (tag: string) => {
const index = user.tags.indexOf(tag); const index = user_.value.tags.indexOf(tag);
if (index > -1) { if (index > -1) {
user.tags.splice(index, 1); user_.value.tags.splice(index, 1);
} }
}; };
const saveChanges = () => { const saveChanges = async () => {
// Implement save logic here console.log("Saving user changes");
console.log("Saving user configuration:", user); let userPayload = {};
alert("User configuration saved successfully!"); if (selectedFile.value) {
try {
const formData = new FormData();
formData.append("file", selectedFile.value);
const response = await fetch(user.urls.api.avatar, {
body: formData,
method: "POST",
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
userPayload.avatar = data.uri;
} catch (error) {
console.error("Failed to upload avatar", error);
}
}
const response = await fetch(user.urls.api.user, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userPayload),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`,
);
}
}; };
const cancelChanges = () => { const cancelChanges = () => {
@ -346,7 +381,7 @@ onMounted(() => {
usersStore.fetchAll().then((users) => { usersStore.fetchAll().then((users) => {
for (const u of users) { for (const u of users) {
if (u.id == props.id) { if (u.id == props.id) {
user.value = u; user_.value = u;
console.log("User set to", u); console.log("User set to", u);
} }
} }