feat: implement asset management system with Unsplash integration and chunked uploads

This commit is contained in:
Paulo Castellano 2026-04-15 10:20:37 -03:00
parent a8bb44f0e5
commit 0d88ced20b
14 changed files with 883 additions and 3 deletions

View 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();
}
}

View 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);
}
}

View file

@ -23,6 +23,7 @@ trait HasMedia
protected static array $mediaCollections = [
Workspace::class => [
'logo' => 'single',
'assets' => 'multiple',
],
User::class => [
'avatar' => 'single',

View 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);
}
}

View file

@ -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
View 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',
],
];

View file

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

View file

@ -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(),

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

View file

@ -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');

View 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);
});