feat: pivot to social media workflow app
This commit is contained in:
79
frontend/src/components/AppAvatar.vue
Normal file
79
frontend/src/components/AppAvatar.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
const initials = computed(() => {
|
||||
const basis = props.name?.trim() || props.email?.trim() || '?';
|
||||
const parts = basis.split(/[\s@._-]+/).filter(Boolean);
|
||||
|
||||
if (!parts.length) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`.toUpperCase();
|
||||
});
|
||||
|
||||
const classes = computed(() => ({
|
||||
avatar: true,
|
||||
'avatar-sm': props.size === 'sm',
|
||||
'avatar-md': props.size === 'md',
|
||||
'avatar-lg': props.size === 'lg',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:alt="name || email || 'Avatar'"
|
||||
/>
|
||||
<span v-else>{{ initials }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
@apply h-full w-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
@apply h-9 w-9 text-xs;
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
@apply h-11 w-11 text-sm;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
@apply h-14 w-14 text-base;
|
||||
}
|
||||
</style>
|
||||
365
frontend/src/components/ImageCropperDialog.vue
Normal file
365
frontend/src/components/ImageCropperDialog.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { Cropper } from 'vue-advanced-cropper';
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Edit image',
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: 'Save image',
|
||||
},
|
||||
aspectRatio: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
uploadLabel: {
|
||||
type: String,
|
||||
default: 'Choose image',
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sourceLabel: {
|
||||
type: String,
|
||||
default: 'Image URL',
|
||||
},
|
||||
loadLabel: {
|
||||
type: String,
|
||||
default: 'Load URL',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const cropper = ref(null);
|
||||
const imageUrl = ref(null);
|
||||
const remoteUrl = ref('');
|
||||
const error = ref(null);
|
||||
|
||||
const isReady = computed(() => Boolean(imageUrl.value));
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function revokeImageUrl() {
|
||||
if (imageUrl.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
revokeImageUrl();
|
||||
imageUrl.value = props.initialUrl || null;
|
||||
remoteUrl.value = props.initialUrl || '';
|
||||
error.value = null;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImage() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event) {
|
||||
const [file] = event.target.files ?? [];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
revokeImageUrl();
|
||||
imageUrl.value = URL.createObjectURL(file);
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
function loadImageFromUrl() {
|
||||
if (!remoteUrl.value) {
|
||||
error.value = 'Enter an image URL before loading it.';
|
||||
return;
|
||||
}
|
||||
|
||||
revokeImageUrl();
|
||||
imageUrl.value = remoteUrl.value;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
function zoom(factor) {
|
||||
cropper.value?.zoom(factor);
|
||||
}
|
||||
|
||||
function rotate(angle) {
|
||||
cropper.value?.rotate(angle);
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
const result = cropper.value?.getResult();
|
||||
const canvas = result?.canvas;
|
||||
|
||||
if (!canvas) {
|
||||
error.value = 'Select an image before saving.';
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 0.92));
|
||||
|
||||
if (!blob) {
|
||||
error.value = 'The edited image could not be prepared.';
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([blob], 'cropped-image.png', { type: 'image/png' });
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.92);
|
||||
await emit('save', { file, dataUrl, sourceUrl: remoteUrl.value || imageUrl.value });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
isOpen => {
|
||||
if (isOpen) {
|
||||
resetState();
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeImageUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="920"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="cropper-card">
|
||||
<div class="cropper-header">
|
||||
<div>
|
||||
<div class="cropper-eyebrow">Image editor</div>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="plain-button"
|
||||
:disabled="isSaving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cropper-actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="action-button"
|
||||
:disabled="isSaving"
|
||||
@click="chooseImage"
|
||||
>
|
||||
{{ uploadLabel }}
|
||||
</button>
|
||||
<div class="url-controls">
|
||||
<input
|
||||
v-model="remoteUrl"
|
||||
type="url"
|
||||
class="url-input"
|
||||
:placeholder="sourceLabel"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="isSaving"
|
||||
@click="loadImageFromUrl"
|
||||
>
|
||||
{{ loadLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="zoom(1.15)"
|
||||
>
|
||||
Zoom in
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="zoom(0.85)"
|
||||
>
|
||||
Zoom out
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="rotate(-90)"
|
||||
>
|
||||
Rotate left
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="rotate(90)"
|
||||
>
|
||||
Rotate right
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="error-message"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="cropper-stage"
|
||||
>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="imageUrl"
|
||||
:stencil-props="{ aspectRatio }"
|
||||
image-restriction="stencil"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
Choose an image to crop and upload.
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="isSaving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="saveCrop"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="isSaving"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cropper-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.cropper-header {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.cropper-eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.cropper-header h2 {
|
||||
@apply mt-2 text-2xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.cropper-actions,
|
||||
.footer-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.url-controls {
|
||||
@apply flex min-w-full flex-wrap gap-3 md:min-w-0 md:flex-1;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply justify-end;
|
||||
}
|
||||
|
||||
.action-button,
|
||||
.plain-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.action-button.secondary,
|
||||
.plain-button {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
color: #172033;
|
||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.cropper-stage {
|
||||
@apply overflow-hidden rounded-[1.5rem] border;
|
||||
height: 28rem;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.error-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
background: rgba(255, 250, 242, 0.9);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
border-color: rgba(185, 28, 28, 0.12);
|
||||
color: #b91c1c;
|
||||
background: rgba(254, 226, 226, 0.75);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user