feat: pivot to social media workflow app
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-24 12:58:35 -04:00
parent 0f4acc1b6d
commit df3e602015
349 changed files with 18685 additions and 16010 deletions

View 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>

View 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>