trypost/resources/js/components/posts/PostForm.vue
Paulo Castellano 07ddedceae fix: resolve all TypeScript errors
- Fix Auth type: add currentWorkspace, workspaces, FlashData
- Fix User.id type: number → string (UUID)
- Fix trans() number params: wrap with String()
- Fix debounce NodeJS.Timeout → ReturnType<typeof setTimeout>
- Fix DatePicker name prop: required → optional
- Fix DatePicker emit typing
- Fix storeStep1 → storeRole import in onboarding/Role
- Fix duplicate id in AcceptInvite interface
- Fix formatDate → formatDateTime in ApiKeys
- Add @unovis/vue and @unovis/ts type declarations
- Install @unovis/vue @unovis/ts dev dependencies
- Add type casts for Inertia/Reka UI component props
2026-03-31 09:34:39 -03:00

411 lines
No EOL
17 KiB
Vue

<script setup lang="ts">
import {
IconPhoto,
IconVideo,
IconX,
IconPlus,
IconCloudUpload,
IconLoader2,
IconGripVertical,
IconCheck,
IconChevronDown,
IconSearch,
} from '@tabler/icons-vue';
import { FocusScope } from 'reka-ui';
import { computed, ref } from 'vue';
import {
Combobox,
ComboboxAnchor,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useMediaRules } from '@/composables/useMediaRules';
interface MediaItem {
id: string;
group_id: string | null;
url: string;
type: string;
original_filename: string;
}
interface ContentTypeOption {
value: string;
label: string;
description: string;
}
interface PinterestBoard {
id: string;
name: string;
}
interface Props {
platform: string;
content: string;
media: MediaItem[];
contentType: string;
contentTypeOptions: ContentTypeOption[];
meta?: Record<string, any>;
platformData?: {
boards?: PinterestBoard[];
};
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isUploading: false,
disabled: false,
});
const emit = defineEmits<{
'update:content': [value: string];
'update:contentType': [value: string];
'update:meta': [value: Record<string, any>];
'upload': [files: File[]];
'remove-media': [mediaId: string];
'reorder-media': [mediaIds: string[]];
}>();
// Media rules
const contentTypeRef = computed(() => props.contentType);
const { rules: mediaRules } = useMediaRules(contentTypeRef);
// File drag and drop (for uploading)
const isDraggingFile = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
// Media reorder drag and drop
const draggedItemId = ref<string | null>(null);
const dragOverItemId = ref<string | null>(null);
// Pinterest board selection
interface BoardOption {
value: string;
label: string;
}
const boards = computed(() => props.platformData?.boards || []);
const boardOptions = computed<BoardOption[]>(() =>
boards.value.map(board => ({
value: board.id,
label: board.name,
}))
);
const selectedBoard = computed({
get: () => boardOptions.value.find(b => b.value === props.meta?.board_id),
set: (board: BoardOption | undefined) => {
emit('update:meta', { ...props.meta, board_id: board?.value || null });
},
});
// Computed
const hasMultipleContentTypes = computed(() => props.contentTypeOptions.length > 1);
const canAddMore = computed(() => props.media.length < mediaRules.value.maxFiles);
const acceptString = computed(() => {
if (mediaRules.value.acceptImages && mediaRules.value.acceptVideos) return 'image/*,video/*';
if (mediaRules.value.acceptImages) return 'image/*';
return 'video/*';
});
// File validation
const isValidFileType = (file: File): boolean => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (isImage && !mediaRules.value.acceptImages) return false;
if (isVideo && !mediaRules.value.acceptVideos) return false;
return isImage || isVideo;
};
// File upload handlers
const handleFileDragOver = (e: DragEvent) => {
e.preventDefault();
// Only handle if dragging files (not internal reorder)
if (e.dataTransfer?.types.includes('Files') && !props.disabled && !props.isUploading && canAddMore.value) {
isDraggingFile.value = true;
}
};
const handleFileDragLeave = (e: DragEvent) => {
e.preventDefault();
isDraggingFile.value = false;
};
const handleFileDrop = (e: DragEvent) => {
e.preventDefault();
isDraggingFile.value = false;
if (props.disabled || props.isUploading || !e.dataTransfer?.files) return;
const files = Array.from(e.dataTransfer.files).filter(isValidFileType);
if (files.length === 0) return;
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
const filesToUpload = files.slice(0, remainingSlots);
if (filesToUpload.length > 0) {
emit('upload', filesToUpload);
}
};
const handleFileSelect = (e: Event) => {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const files = Array.from(input.files).filter(isValidFileType);
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
const filesToUpload = files.slice(0, remainingSlots);
if (filesToUpload.length > 0) {
emit('upload', filesToUpload);
}
input.value = '';
};
const openFilePicker = () => {
if (!props.disabled && !props.isUploading && canAddMore.value) {
fileInput.value?.click();
}
};
// Media reorder handlers
const handleDragStart = (e: DragEvent, itemId: string) => {
if (props.disabled) return;
draggedItemId.value = itemId;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', itemId);
}
};
const handleDragEnd = () => {
draggedItemId.value = null;
dragOverItemId.value = null;
};
const handleDragOverItem = (e: DragEvent, itemId: string) => {
e.preventDefault();
if (draggedItemId.value && draggedItemId.value !== itemId) {
dragOverItemId.value = itemId;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
}
};
const handleDragLeaveItem = () => {
dragOverItemId.value = null;
};
const handleDropOnItem = (e: DragEvent, targetId: string) => {
e.preventDefault();
if (!draggedItemId.value || draggedItemId.value === targetId) {
handleDragEnd();
return;
}
// Calculate new order
const currentIds = props.media.map(m => m.id);
const draggedIndex = currentIds.indexOf(draggedItemId.value);
const targetIndex = currentIds.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) {
handleDragEnd();
return;
}
// Create new order by moving dragged item to target position
const newIds = [...currentIds];
newIds.splice(draggedIndex, 1);
newIds.splice(targetIndex, 0, draggedItemId.value);
emit('reorder-media', newIds);
handleDragEnd();
};
</script>
<template>
<div class="space-y-6">
<!-- Content Type Selector (Tabs Style) -->
<div v-if="hasMultipleContentTypes">
<Label class="text-sm font-medium mb-2 block">{{ $t('posts.form.post_type') }}</Label>
<div
class="bg-muted text-muted-foreground inline-flex h-10 w-auto items-center justify-start rounded-lg p-1">
<button v-for="option in contentTypeOptions" :key="option.value" type="button"
@click="emit('update:contentType', option.value)" :class="[
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all',
contentType === option.value
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-background/50 hover:text-foreground'
]">
{{ option.label }}
</button>
</div>
</div>
<!-- Pinterest Board Selector -->
<div v-if="platform === 'pinterest' && boards.length > 0">
<Label class="text-sm font-medium mb-2 block">{{ $t('posts.form.board') }}</Label>
<FocusScope as-child>
<Combobox v-model="selectedBoard">
<ComboboxAnchor as-child>
<ComboboxTrigger as-child>
<button type="button"
class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{{ selectedBoard ? selectedBoard.label : $t('posts.form.select_board') }}
<IconChevronDown class="h-4 w-4 opacity-50" />
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
<div class="relative">
<ComboboxInput :placeholder="$t('posts.form.search_board')" />
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-3">
<IconSearch class="size-4 text-muted-foreground" />
</span>
</div>
<ComboboxEmpty>{{ $t('posts.form.no_board_found') }}</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem v-for="board in boardOptions" :key="board.value" :value="board">
<span class="min-w-0 flex-1 truncate">{{ board.label }}</span>
<ComboboxItemIndicator>
<IconCheck class="ml-auto h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
</FocusScope>
</div>
<!-- Media Upload Area -->
<div>
<Label class="text-sm font-medium mb-2 block">
{{ $t('posts.form.media') }}
<span class="text-muted-foreground font-normal ml-1">
({{ props.media.length }}/{{ mediaRules.maxFiles }})
</span>
<span v-if="mediaRules.minFiles && mediaRules.minFiles > 1" class="text-muted-foreground font-normal">
· {{ $t('posts.form.min') }} {{ mediaRules.minFiles }}
</span>
</Label>
<div class="border-2 border-dashed rounded-lg transition-colors" :class="[
isDraggingFile ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/40',
props.disabled ? 'opacity-50 cursor-not-allowed' : '',
]" @dragover="handleFileDragOver" @dragleave="handleFileDragLeave" @drop="handleFileDrop">
<!-- Empty State -->
<div v-if="media.length === 0" class="p-8 flex flex-col items-center justify-center cursor-pointer"
@click="openFilePicker">
<div v-if="isUploading" class="flex flex-col items-center gap-3">
<IconLoader2 class="h-10 w-10 text-muted-foreground animate-spin" />
<p class="text-sm text-muted-foreground">{{ $t('posts.form.uploading') }}</p>
</div>
<div v-else class="flex flex-col items-center gap-3">
<div class="p-4 rounded-full transition-colors"
:class="isDraggingFile ? 'bg-primary/10' : 'bg-muted'">
<IconCloudUpload v-if="isDraggingFile" class="h-8 w-8 text-primary" />
<IconVideo v-else-if="!mediaRules.acceptImages" class="h-8 w-8 text-muted-foreground" />
<IconPhoto v-else class="h-8 w-8 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-sm font-medium">
{{ isDraggingFile ? $t('posts.form.drop_to_upload') : $t('posts.form.drag_and_drop') }}
</p>
<p class="text-xs text-muted-foreground mt-1">
{{ mediaRules.acceptImages && mediaRules.acceptVideos ? $t('posts.form.photos_and_videos') :
(mediaRules.acceptImages ? $t('posts.form.photos_only') : $t('posts.form.videos_only')) }}
</p>
</div>
</div>
</div>
<!-- Media Grid -->
<div v-else class="p-3">
<div class="grid grid-cols-3 gap-2">
<!-- Media Items -->
<div v-for="(item, index) in media" :key="item.id" draggable="true"
@dragstart="handleDragStart($event, item.id)" @dragend="handleDragEnd"
@dragover="handleDragOverItem($event, item.id)" @dragleave="handleDragLeaveItem"
@drop="handleDropOnItem($event, item.id)"
class="relative aspect-square rounded-lg overflow-hidden bg-muted group transition-all"
:class="[
draggedItemId === item.id ? 'opacity-50 scale-95' : '',
dragOverItemId === item.id ? 'ring-2 ring-primary ring-offset-2' : '',
!props.disabled ? 'cursor-grab active:cursor-grabbing' : '',
]">
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover pointer-events-none" />
<video v-else :src="item.url"
class="w-full h-full object-cover bg-black pointer-events-none" muted playsinline />
<!-- Order number -->
<div v-if="media.length > 1"
class="absolute top-1 left-1 bg-black/70 text-white text-[10px] font-bold w-5 h-5 rounded flex items-center justify-center">
{{ index + 1 }}
</div>
<!-- Video indicator -->
<div v-if="item.type === 'video'"
class="absolute bottom-1 left-1 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5">
<IconVideo class="h-3 w-3" />
</div>
<!-- Remove button -->
<button v-if="!props.disabled" type="button" @click.stop="emit('remove-media', item.id)"
class="absolute top-1 right-1 bg-black/70 hover:bg-black/90 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<IconX class="h-3.5 w-3.5" />
</button>
<!-- Drag hint -->
<div v-if="media.length > 1 && !props.disabled"
class="absolute bottom-1 right-1 bg-black/50 text-white rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<IconGripVertical class="h-3.5 w-3.5" />
</div>
</div>
<!-- Add More Button -->
<button v-if="canAddMore && !props.disabled" type="button" @click="openFilePicker"
class="aspect-square rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
:class="isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-muted/50'">
<IconLoader2 v-if="isUploading" class="h-6 w-6 text-muted-foreground animate-spin" />
<IconPlus v-else class="h-6 w-6 text-muted-foreground" />
</button>
</div>
<p v-if="media.length > 1" class="text-xs text-muted-foreground mt-2 text-center">
{{ $t('posts.form.drag_to_reorder') }}
</p>
</div>
</div>
<!-- Hidden file input -->
<input ref="fileInput" type="file" :accept="acceptString" :multiple="mediaRules.maxFiles > 1" class="hidden"
@change="handleFileSelect" :disabled="props.disabled || props.isUploading" />
</div>
<!-- Caption/Text Input -->
<div>
<div class="flex items-center justify-between mb-2">
<Label class="text-sm font-medium">{{ $t('posts.form.caption') }}</Label>
<span class="text-xs px-2 py-0.5 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'">
{{ validationMessage }}
</span>
</div>
<Textarea :model-value="content" @update:model-value="emit('update:content', $event as string)"
:placeholder="$t('posts.form.write_caption')" class="min-h-[120px] resize-none" :disabled="props.disabled" />
</div>
</div>
</template>
33