Create API for adding an avatar to a user
This commit is contained in:
parent
da7549eeda
commit
ad90f9c95e
7 changed files with 131 additions and 24 deletions
26
api/avatar.go
Normal file
26
api/avatar.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
40
platform/avatar.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const (
|
||||||
CollectionAudioRaw Collection = iota
|
CollectionAudioRaw Collection = iota
|
||||||
CollectionAudioNormalized
|
CollectionAudioNormalized
|
||||||
CollectionAudioTranscoded
|
CollectionAudioTranscoded
|
||||||
|
CollectionAvatar
|
||||||
CollectionCSV
|
CollectionCSV
|
||||||
CollectionImageRaw
|
CollectionImageRaw
|
||||||
CollectionLogo
|
CollectionLogo
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue