feat: implement asset management system with Unsplash integration and chunked uploads
This commit is contained in:
parent
a8bb44f0e5
commit
0d88ced20b
14 changed files with 883 additions and 3 deletions
208
app/Http/Controllers/App/AssetController.php
Normal file
208
app/Http/Controllers/App/AssetController.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Services\UnsplashService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class AssetController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$assets = $workspace->getMedia('assets')
|
||||
->latest()
|
||||
->paginate(24);
|
||||
|
||||
return Inertia::render('assets/Index', [
|
||||
'assets' => Inertia::scroll(fn () => $assets),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$request->validate([
|
||||
'media' => ['required', 'file', 'max:1048576', 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4'], // max 1GB in KB
|
||||
]);
|
||||
|
||||
$media = $workspace->addMedia($request->file('media'), 'assets');
|
||||
|
||||
return response()->json([
|
||||
'id' => $media->id,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type->value,
|
||||
'original_filename' => $media->original_filename,
|
||||
'size' => $media->size,
|
||||
'meta' => $media->meta,
|
||||
'created_at' => $media->created_at->toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeChunked(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$contentRange = $request->header('Content-Range');
|
||||
|
||||
preg_match('/bytes (\d+)-(\d+)\/(\d+)/', $contentRange, $matches);
|
||||
|
||||
if (empty($matches)) {
|
||||
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Invalid Content-Range header');
|
||||
}
|
||||
|
||||
$rangeStart = (int) $matches[1];
|
||||
$rangeEnd = (int) $matches[2];
|
||||
$totalSize = (int) $matches[3];
|
||||
|
||||
$fileName = $request->header('X-File-Name', 'upload');
|
||||
|
||||
if (! preg_match('/\.(jpe?g|png|gif|webp|mp4)$/i', $fileName)) {
|
||||
abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File type not supported.');
|
||||
}
|
||||
|
||||
if ($totalSize > 1073741824) { // 1GB
|
||||
abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File size exceeds the maximum allowed (1GB).');
|
||||
}
|
||||
$identifier = md5($request->user()->id.$fileName.$totalSize);
|
||||
$tempFile = storage_path("app/private/chunks/{$identifier}");
|
||||
|
||||
$directory = dirname($tempFile);
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($tempFile, $request->getContent(), $rangeStart === 0 ? 0 : FILE_APPEND);
|
||||
|
||||
$isLastChunk = ($rangeEnd + 1) >= $totalSize;
|
||||
|
||||
if (! $isLastChunk) {
|
||||
return response()->json([
|
||||
'done' => false,
|
||||
'progress' => (int) round(($rangeEnd + 1) / $totalSize * 100),
|
||||
]);
|
||||
}
|
||||
|
||||
$media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets');
|
||||
|
||||
@unlink($tempFile);
|
||||
|
||||
return response()->json([
|
||||
'done' => true,
|
||||
'id' => $media->id,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type->value,
|
||||
'original_filename' => $media->original_filename,
|
||||
'size' => $media->size,
|
||||
'meta' => $media->meta,
|
||||
'created_at' => $media->created_at->toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeFromUrl(Request $request, UnsplashService $unsplash): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$validated = $request->validate([
|
||||
'url' => ['required', 'url', 'regex:/^https:\/\/images\.unsplash\.com\//'],
|
||||
'filename' => ['required', 'string', 'max:255'],
|
||||
'download_location' => ['sometimes', 'url', 'regex:/^https:\/\/api\.unsplash\.com\//'],
|
||||
]);
|
||||
|
||||
// Trigger Unsplash download tracking (required by API guidelines)
|
||||
if ($downloadLocation = data_get($validated, 'download_location')) {
|
||||
$unsplash->trackDownload($downloadLocation);
|
||||
}
|
||||
|
||||
$response = Http::timeout(30)->get(data_get($validated, 'url'));
|
||||
|
||||
if ($response->failed()) {
|
||||
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
||||
}
|
||||
|
||||
$mimeType = $response->header('Content-Type', 'image/jpeg');
|
||||
$extension = match (true) {
|
||||
str_contains($mimeType, 'png') => 'png',
|
||||
str_contains($mimeType, 'gif') => 'gif',
|
||||
str_contains($mimeType, 'webp') => 'webp',
|
||||
default => 'jpg',
|
||||
};
|
||||
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$path = 'medias/'.$filename;
|
||||
|
||||
Storage::put($path, $response->body());
|
||||
|
||||
$meta = [];
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'unsplash');
|
||||
file_put_contents($tempFile, $response->body());
|
||||
$imageInfo = @getimagesize($tempFile);
|
||||
if ($imageInfo) {
|
||||
$meta['width'] = $imageInfo[0];
|
||||
$meta['height'] = $imageInfo[1];
|
||||
}
|
||||
@unlink($tempFile);
|
||||
|
||||
$media = $workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'image',
|
||||
'path' => $path,
|
||||
'original_filename' => data_get($validated, 'filename'),
|
||||
'mime_type' => $mimeType,
|
||||
'size' => strlen($response->body()),
|
||||
'order' => 0,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $media->id,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type->value,
|
||||
'original_filename' => $media->original_filename,
|
||||
'size' => $media->size,
|
||||
'meta' => $media->meta,
|
||||
'created_at' => $media->created_at->toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Media $media): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($media->mediable_type !== $workspace->getMorphClass() || $media->mediable_id !== $workspace->id) {
|
||||
abort(SymfonyResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$media->delete();
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/App/UnsplashController.php
Normal file
31
app/Http/Controllers/App/UnsplashController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Services\UnsplashService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UnsplashController extends Controller
|
||||
{
|
||||
public function search(Request $request, UnsplashService $unsplash): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$request->validate([
|
||||
'query' => ['required', 'string', 'max:255'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$results = $unsplash->search(
|
||||
query: $request->input('query'),
|
||||
page: $request->integer('page', 1),
|
||||
);
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ trait HasMedia
|
|||
protected static array $mediaCollections = [
|
||||
Workspace::class => [
|
||||
'logo' => 'single',
|
||||
'assets' => 'multiple',
|
||||
],
|
||||
User::class => [
|
||||
'avatar' => 'single',
|
||||
|
|
|
|||
69
app/Services/UnsplashService.php
Normal file
69
app/Services/UnsplashService.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UnsplashService
|
||||
{
|
||||
private string $baseUrl = 'https://api.unsplash.com';
|
||||
|
||||
private string $accessKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accessKey = config('services.unsplash.access_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{results: array<int, array<string, mixed>>, total: int, total_pages: int}
|
||||
*/
|
||||
public function search(string $query, int $page = 1, int $perPage = 30): array
|
||||
{
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['Authorization' => "Client-ID {$this->accessKey}"])
|
||||
->get("{$this->baseUrl}/search/photos", [
|
||||
'query' => $query,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'orientation' => 'landscape',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Unsplash search failed', ['body' => $response->body()]);
|
||||
|
||||
return ['results' => [], 'total' => 0, 'total_pages' => 0];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'results' => collect(data_get($data, 'results', []))->map(fn (array $photo) => [
|
||||
'id' => data_get($photo, 'id'),
|
||||
'url_small' => data_get($photo, 'urls.small'),
|
||||
'url_regular' => data_get($photo, 'urls.regular'),
|
||||
'url_full' => data_get($photo, 'urls.full'),
|
||||
'download_location' => data_get($photo, 'links.download_location'),
|
||||
'description' => data_get($photo, 'alt_description'),
|
||||
'width' => data_get($photo, 'width'),
|
||||
'height' => data_get($photo, 'height'),
|
||||
'author' => [
|
||||
'name' => data_get($photo, 'user.name'),
|
||||
'url' => data_get($photo, 'user.links.html'),
|
||||
],
|
||||
])->all(),
|
||||
'total' => data_get($data, 'total', 0),
|
||||
'total_pages' => data_get($data, 'total_pages', 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function trackDownload(string $downloadLocation): void
|
||||
{
|
||||
Http::timeout(5)
|
||||
->withHeaders(['Authorization' => "Client-ID {$this->accessKey}"])
|
||||
->get($downloadLocation);
|
||||
}
|
||||
}
|
||||
|
|
@ -113,4 +113,9 @@
|
|||
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
|
||||
],
|
||||
|
||||
'unsplash' => [
|
||||
'access_key' => env('UNSPLASH_ACCESS_KEY'),
|
||||
'secret_key' => env('UNSPLASH_SECRET_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
36
lang/en/assets.php
Normal file
36
lang/en/assets.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Assets',
|
||||
|
||||
'tabs' => [
|
||||
'my_uploads' => 'My Uploads',
|
||||
'stock_photos' => 'Stock Photos',
|
||||
],
|
||||
|
||||
'upload' => [
|
||||
'drag_drop' => 'Drag & drop your files here, or click to select',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4',
|
||||
'uploading' => 'Uploading...',
|
||||
],
|
||||
|
||||
'empty' => [
|
||||
'title' => 'No assets yet',
|
||||
'description' => 'Upload images and videos to build your media library.',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Delete asset',
|
||||
'description' => 'Are you sure you want to delete this asset? This action cannot be undone.',
|
||||
'confirm' => 'Delete',
|
||||
'cancel' => 'Cancel',
|
||||
],
|
||||
|
||||
'unsplash' => [
|
||||
'search_placeholder' => 'Search free photos...',
|
||||
'no_results' => 'No photos found',
|
||||
'no_results_description' => 'Try a different search term.',
|
||||
'start_searching' => 'Search for free stock photos from Unsplash',
|
||||
'load_more' => 'Load more',
|
||||
],
|
||||
];
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
'connections' => 'Connections',
|
||||
'hashtags' => 'Hashtags',
|
||||
'labels' => 'Labels',
|
||||
'assets' => 'Assets',
|
||||
'api_keys' => 'API Keys',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -13,6 +13,7 @@ import {
|
|||
IconHash,
|
||||
IconKey,
|
||||
IconLifebuoy,
|
||||
IconPhoto,
|
||||
IconMessageCircle,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
|
|
@ -52,6 +53,7 @@ import { edit as accountSettings } from '@/routes/app/account';
|
|||
import { index as apiKeys } from '@/routes/app/api-keys';
|
||||
import { index as billing } from '@/routes/app/billing';
|
||||
import { index as usage } from '@/routes/app/usage';
|
||||
import { index as assets } from '@/routes/app/assets';
|
||||
import { index as hashtags } from '@/routes/app/hashtags';
|
||||
import { index as labels } from '@/routes/app/labels';
|
||||
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
|
|
@ -130,6 +132,11 @@ const workspaceNavItems = computed<NavItem[]>(() => [
|
|||
href: labels.url(),
|
||||
icon: IconTag,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.assets'),
|
||||
href: assets.url(),
|
||||
icon: IconPhoto,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.api_keys'),
|
||||
href: apiKeys.url(),
|
||||
|
|
|
|||
371
resources/js/pages/assets/Index.vue
Normal file
371
resources/js/pages/assets/Index.vue
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll, router, useHttp } from '@inertiajs/vue3';
|
||||
import { IconCloudUpload, IconDownload, IconPhoto, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as assetsIndex, destroy as assetsDestroy, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
|
||||
import { search as unsplashSearch } from '@/routes/app/assets/unsplash';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
interface AssetMedia {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
size: number;
|
||||
meta: { width?: number; height?: number } | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ScrollAssets {
|
||||
data: AssetMedia[];
|
||||
meta: {
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UnsplashPhoto {
|
||||
id: string;
|
||||
url_small: string;
|
||||
url_regular: string;
|
||||
url_full: string;
|
||||
download_location: string;
|
||||
description: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
author: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
assets: ScrollAssets;
|
||||
}>();
|
||||
|
||||
const http = useHttp({});
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('assets.title'), href: assetsIndex.url() },
|
||||
]);
|
||||
|
||||
// Upload
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const uploading = ref(false);
|
||||
|
||||
const triggerFileInput = () => fileInput.value?.click();
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
uploadFiles(Array.from(target.files));
|
||||
target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false;
|
||||
if (event.dataTransfer?.files) {
|
||||
uploadFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
uploading.value = true;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('media', file);
|
||||
|
||||
try {
|
||||
await http.post(assetsStore.url(), formData);
|
||||
} catch {
|
||||
// Silently handle individual file failures
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
router.reload({ only: ['assets'], reset: ['assets'] });
|
||||
};
|
||||
|
||||
// Delete
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const handleDelete = (assetId: string) => {
|
||||
deleteModal.value?.open({
|
||||
url: assetsDestroy.url(assetId),
|
||||
});
|
||||
};
|
||||
|
||||
// Unsplash
|
||||
const unsplashQuery = ref('');
|
||||
const unsplashResults = ref<UnsplashPhoto[]>([]);
|
||||
const unsplashPage = ref(1);
|
||||
const unsplashTotalPages = ref(0);
|
||||
const unsplashLoading = ref(false);
|
||||
const savingPhotoId = ref<string | null>(null);
|
||||
|
||||
const searchUnsplash = debounce(async () => {
|
||||
if (!unsplashQuery.value.trim()) {
|
||||
unsplashResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
unsplashLoading.value = true;
|
||||
unsplashPage.value = 1;
|
||||
|
||||
try {
|
||||
const response = await http.get(unsplashSearch.url({ query: { query: unsplashQuery.value, page: 1 } }));
|
||||
unsplashResults.value = response.results;
|
||||
unsplashTotalPages.value = response.total_pages;
|
||||
} catch {
|
||||
unsplashResults.value = [];
|
||||
} finally {
|
||||
unsplashLoading.value = false;
|
||||
}
|
||||
}, 400);
|
||||
|
||||
const loadMoreUnsplash = async () => {
|
||||
if (unsplashPage.value >= unsplashTotalPages.value || unsplashLoading.value) return;
|
||||
|
||||
unsplashLoading.value = true;
|
||||
unsplashPage.value++;
|
||||
|
||||
try {
|
||||
const response = await http.get(unsplashSearch.url({ query: { query: unsplashQuery.value, page: unsplashPage.value } }));
|
||||
unsplashResults.value.push(...response.results);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
unsplashLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveFromUnsplash = async (photo: UnsplashPhoto) => {
|
||||
savingPhotoId.value = photo.id;
|
||||
|
||||
try {
|
||||
await http.post(storeFromUrl.url(), {
|
||||
url: photo.url_regular,
|
||||
filename: `unsplash-${photo.id}.jpg`,
|
||||
download_location: photo.download_location,
|
||||
});
|
||||
|
||||
router.reload({ only: ['assets'], reset: ['assets'] });
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
savingPhotoId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('assets.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<Tabs default-value="uploads">
|
||||
<TabsList>
|
||||
<TabsTrigger value="uploads">{{ $t('assets.tabs.my_uploads') }}</TabsTrigger>
|
||||
<TabsTrigger value="stock">{{ $t('assets.tabs.stock_photos') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- My Uploads -->
|
||||
<TabsContent value="uploads" class="mt-6">
|
||||
<!-- Upload Zone -->
|
||||
<div
|
||||
class="relative mb-6 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors"
|
||||
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<IconCloudUpload class="mb-3 size-10 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">{{ $t('assets.upload.drag_drop') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ $t('assets.upload.formats') }}</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{{ $t('assets.upload.uploading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid -->
|
||||
<EmptyState
|
||||
v-if="assets.data.length === 0 && !uploading"
|
||||
:icon="IconPhoto"
|
||||
:title="$t('assets.empty.title')"
|
||||
:description="$t('assets.empty.description')"
|
||||
/>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="asset in assets.data"
|
||||
:key="asset.id"
|
||||
class="group relative overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<div class="aspect-square">
|
||||
<video
|
||||
v-if="asset.type === 'video'"
|
||||
:src="asset.url"
|
||||
class="size-full object-cover"
|
||||
muted
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="asset.url"
|
||||
:alt="asset.original_filename"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
@click="handleDelete(asset.id)"
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<p class="truncate text-xs font-medium text-white">{{ asset.original_filename }}</p>
|
||||
<p class="text-xs text-white/70">{{ formatFileSize(asset.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfiniteScroll data="assets" #default="{ loading }">
|
||||
<div v-if="loading" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<Skeleton v-for="i in 5" :key="i" class="aspect-square rounded-lg" />
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Stock Photos (Unsplash) -->
|
||||
<TabsContent value="stock" class="mt-6">
|
||||
<div class="relative mb-6">
|
||||
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="unsplashQuery"
|
||||
:placeholder="$t('assets.unsplash.search_placeholder')"
|
||||
class="pl-9"
|
||||
@input="searchUnsplash"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Unsplash Results Grid -->
|
||||
<div
|
||||
v-if="unsplashResults.length > 0"
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="photo in unsplashResults"
|
||||
:key="photo.id"
|
||||
class="group relative overflow-hidden rounded-lg bg-muted"
|
||||
>
|
||||
<div class="aspect-[4/3]">
|
||||
<img
|
||||
:src="photo.url_small"
|
||||
:alt="photo.description || 'Unsplash photo'"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
:disabled="savingPhotoId === photo.id"
|
||||
@click="saveFromUnsplash(photo)"
|
||||
>
|
||||
<div v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<IconDownload v-else class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
:href="photo.author.url + '?utm_source=trypost&utm_medium=referral'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-white/80 hover:text-white"
|
||||
>
|
||||
{{ photo.author.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="unsplashQuery && !unsplashLoading"
|
||||
:icon="IconSearch"
|
||||
:title="$t('assets.unsplash.no_results')"
|
||||
:description="$t('assets.unsplash.no_results_description')"
|
||||
/>
|
||||
|
||||
<div v-else-if="!unsplashQuery" class="flex flex-col items-center py-16 text-muted-foreground">
|
||||
<IconPhoto class="mb-3 size-10" />
|
||||
<p class="text-sm">{{ $t('assets.unsplash.start_searching') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="unsplashLoading" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-if="unsplashResults.length > 0 && unsplashPage < unsplashTotalPages" class="mt-6 flex justify-center">
|
||||
<Button variant="outline" :disabled="unsplashLoading" @click="loadMoreUnsplash">
|
||||
{{ $t('assets.unsplash.load_more') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
:title="$t('assets.delete.title')"
|
||||
:description="$t('assets.delete.description')"
|
||||
:action="$t('assets.delete.confirm')"
|
||||
:cancel="$t('assets.delete.cancel')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Http\Controllers\App\AnalyticsController;
|
||||
use App\Http\Controllers\App\ApiKeyController;
|
||||
use App\Http\Controllers\App\AssetController;
|
||||
use App\Http\Controllers\App\BillingController;
|
||||
use App\Http\Controllers\App\MediaController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
use App\Http\Controllers\App\Settings\PasswordController;
|
||||
use App\Http\Controllers\App\Settings\ProfileController;
|
||||
use App\Http\Controllers\App\Settings\UsageController;
|
||||
use App\Http\Controllers\App\UnsplashController;
|
||||
use App\Http\Controllers\App\WorkspaceController;
|
||||
use App\Http\Controllers\App\WorkspaceHashtagController;
|
||||
use App\Http\Controllers\App\WorkspaceInviteController;
|
||||
|
|
@ -158,6 +160,14 @@
|
|||
Route::put('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'update'])->name('app.hashtags.update');
|
||||
Route::delete('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'destroy'])->name('app.hashtags.destroy');
|
||||
|
||||
// Assets
|
||||
Route::get('assets', [AssetController::class, 'index'])->name('app.assets.index');
|
||||
Route::post('assets', [AssetController::class, 'store'])->name('app.assets.store');
|
||||
Route::post('assets/chunked', [AssetController::class, 'storeChunked'])->name('app.assets.store-chunked');
|
||||
Route::post('assets/from-url', [AssetController::class, 'storeFromUrl'])->name('app.assets.store-from-url');
|
||||
Route::delete('assets/{media}', [AssetController::class, 'destroy'])->name('app.assets.destroy');
|
||||
Route::get('assets/unsplash/search', [UnsplashController::class, 'search'])->name('app.assets.unsplash.search');
|
||||
|
||||
// Labels
|
||||
Route::get('labels', [WorkspaceLabelController::class, 'index'])->name('app.labels.index');
|
||||
Route::post('labels', [WorkspaceLabelController::class, 'store'])->name('app.labels.store');
|
||||
|
|
|
|||
144
tests/Feature/AssetControllerTest.php
Normal file
144
tests/Feature/AssetControllerTest.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\UnsplashService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
});
|
||||
|
||||
test('assets index shows assets page', function () {
|
||||
$response = $this->actingAs($this->user)->get(route('app.assets.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('assets/Index', false)
|
||||
->has('assets')
|
||||
);
|
||||
});
|
||||
|
||||
test('assets index requires authentication', function () {
|
||||
$response = $this->get(route('app.assets.index'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('can upload an image asset', function () {
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.assets.store'), ['media' => $file]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['id', 'url', 'type', 'original_filename', 'size']);
|
||||
|
||||
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
||||
|
||||
$media = $this->workspace->getMedia('assets')->first();
|
||||
expect($media->original_filename)->toBe('photo.jpg');
|
||||
expect($media->collection)->toBe('assets');
|
||||
});
|
||||
|
||||
test('can delete an asset', function () {
|
||||
$file = UploadedFile::fake()->image('photo.jpg');
|
||||
$media = $this->workspace->addMedia($file, 'assets');
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->delete(route('app.assets.destroy', $media));
|
||||
|
||||
$response->assertRedirect();
|
||||
expect(Media::find($media->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot delete asset from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->image('photo.jpg');
|
||||
$media = $otherWorkspace->addMedia($file, 'assets');
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->delete(route('app.assets.destroy', $media));
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('can store asset from url', function () {
|
||||
$fakeImage = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
$imageContent = file_get_contents($fakeImage->getPathname());
|
||||
|
||||
Http::fake([
|
||||
'images.unsplash.com/*' => Http::response(
|
||||
$imageContent,
|
||||
200,
|
||||
['Content-Type' => 'image/jpeg']
|
||||
),
|
||||
]);
|
||||
|
||||
$unsplash = $this->mock(UnsplashService::class);
|
||||
$unsplash->shouldReceive('trackDownload')->once();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.assets.store-from-url'), [
|
||||
'url' => 'https://images.unsplash.com/photo-test',
|
||||
'filename' => 'unsplash-test.jpg',
|
||||
'download_location' => 'https://api.unsplash.com/photos/test/download',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['id', 'url', 'type', 'original_filename']);
|
||||
|
||||
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('unsplash search returns results', function () {
|
||||
$this->mock(UnsplashService::class)
|
||||
->shouldReceive('search')
|
||||
->with('nature', 1)
|
||||
->once()
|
||||
->andReturn([
|
||||
'results' => [
|
||||
['id' => 'abc', 'url_small' => 'https://example.com/small.jpg'],
|
||||
],
|
||||
'total' => 1,
|
||||
'total_pages' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.assets.unsplash.search', ['query' => 'nature']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('total', 1);
|
||||
});
|
||||
Loading…
Reference in a new issue