2026-04-15 13:20:37 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\App;
|
|
|
|
|
|
feat: complete AI assistant with custom services and brand config
Baseline snapshot of custom AI implementation before Laravel AI SDK migration.
Includes:
- Custom services: GeminiTextGenerationService, TextGenerationService (OpenAI), ImageGenerationService, AudioGenerationService, VideoGenerationService
- IntentDetector for content moderation via keyword matching
- AI enums: Intent, Orientation, UsageType
- Blade prompt templates: system.blade.php, image.blade.php, video.blade.php
- AiMessage with content_html accessor (markdown rendering)
- AiUsageLog for monthly quota tracking per account
- PostAssistantController with regex-based [GENERATE_*] parsing
- WritingAssistantTab with markdown rendering, add-to-post, attachments
- Workspace brand fields (name, description, tone, voice_notes) in system prompt
- Session state block injected into prompts (thread counts, quota remaining)
- AttachmentCollector pattern will replace the regex approach in Phase 2
- Post comments with replies, emoji reactions, real-time via Echo
- Assets page with Unsplash + Giphy integrations
2026-04-16 12:25:08 +00:00
|
|
|
use App\Http\Requests\App\Asset\StoreAssetFromUrlRequest;
|
|
|
|
|
use App\Http\Requests\App\Asset\StoreAssetRequest;
|
2026-05-04 17:54:47 +00:00
|
|
|
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
|
2026-04-17 02:05:51 +00:00
|
|
|
use App\Http\Resources\App\MediaResource;
|
2026-04-15 13:20:37 +00:00
|
|
|
use App\Models\Media;
|
2026-07-17 18:40:15 +00:00
|
|
|
use App\Services\Brand\SafeHttpFetcher;
|
2026-04-15 13:20:37 +00:00
|
|
|
use App\Services\UnsplashService;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Http\RedirectResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
2026-04-15 13:20:37 +00:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
use Inertia\Inertia;
|
|
|
|
|
use Inertia\Response;
|
2026-07-17 18:40:15 +00:00
|
|
|
use RuntimeException;
|
2026-04-15 13:20:37 +00:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
return Inertia::render('assets/Index');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function search(Request $request): AnonymousResourceCollection
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
|
|
|
|
|
|
$term = trim((string) $request->input('search', ''));
|
|
|
|
|
$type = $request->input('type');
|
|
|
|
|
|
2026-04-15 13:20:37 +00:00
|
|
|
$assets = $workspace->getMedia('assets')
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
->when($term !== '', fn ($query) => $query->where('original_filename', 'ilike', '%'.$term.'%'))
|
|
|
|
|
->when(in_array($type, ['image', 'video'], true), fn ($query) => $query->where('type', $type))
|
2026-04-15 13:20:37 +00:00
|
|
|
->latest()
|
2026-04-15 14:02:10 +00:00
|
|
|
->paginate(config('app.pagination.default'));
|
2026-04-15 13:20:37 +00:00
|
|
|
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
return MediaResource::collection($assets);
|
2026-04-15 13:20:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
public function store(StoreAssetRequest $request): MediaResource
|
2026-04-15 13:20:37 +00:00
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
$clientMeta = (array) $request->input('meta', []);
|
|
|
|
|
|
|
|
|
|
$media = $workspace->addMedia($request->file('media'), 'assets', $clientMeta);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
return new MediaResource($media);
|
2026-04-15 13:20:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 17:54:47 +00:00
|
|
|
public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
|
2026-04-15 13:20:37 +00:00
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
2026-05-04 17:54:47 +00:00
|
|
|
$rangeStart = (int) $request->validated('range_start');
|
|
|
|
|
$rangeEnd = (int) $request->validated('range_end');
|
|
|
|
|
$totalSize = (int) $request->validated('total_size');
|
|
|
|
|
$fileName = (string) $request->validated('file_name');
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
$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),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 13:55:57 +00:00
|
|
|
// Finalize may stream hundreds of MB to object storage (R2/S3).
|
|
|
|
|
set_time_limit(0);
|
|
|
|
|
|
2026-04-15 13:20:37 +00:00
|
|
|
$media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets');
|
|
|
|
|
|
|
|
|
|
@unlink($tempFile);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'done' => true,
|
|
|
|
|
'id' => $media->id,
|
2026-04-15 23:11:36 +00:00
|
|
|
'path' => $media->path,
|
2026-04-15 13:20:37 +00:00
|
|
|
'url' => $media->url,
|
|
|
|
|
'type' => $media->type->value,
|
2026-04-15 23:11:36 +00:00
|
|
|
'mime_type' => $media->mime_type,
|
2026-04-15 13:20:37 +00:00
|
|
|
'original_filename' => $media->original_filename,
|
|
|
|
|
'size' => $media->size,
|
|
|
|
|
'meta' => $media->meta,
|
|
|
|
|
'created_at' => $media->created_at->toISOString(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:40:15 +00:00
|
|
|
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource
|
2026-04-15 13:20:37 +00:00
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
feat: complete AI assistant with custom services and brand config
Baseline snapshot of custom AI implementation before Laravel AI SDK migration.
Includes:
- Custom services: GeminiTextGenerationService, TextGenerationService (OpenAI), ImageGenerationService, AudioGenerationService, VideoGenerationService
- IntentDetector for content moderation via keyword matching
- AI enums: Intent, Orientation, UsageType
- Blade prompt templates: system.blade.php, image.blade.php, video.blade.php
- AiMessage with content_html accessor (markdown rendering)
- AiUsageLog for monthly quota tracking per account
- PostAssistantController with regex-based [GENERATE_*] parsing
- WritingAssistantTab with markdown rendering, add-to-post, attachments
- Workspace brand fields (name, description, tone, voice_notes) in system prompt
- Session state block injected into prompts (thread counts, quota remaining)
- AttachmentCollector pattern will replace the regex approach in Phase 2
- Post comments with replies, emoji reactions, real-time via Echo
- Assets page with Unsplash + Giphy integrations
2026-04-16 12:25:08 +00:00
|
|
|
$validated = $request->validated();
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
// Trigger Unsplash download tracking (required by API guidelines)
|
|
|
|
|
if ($downloadLocation = data_get($validated, 'download_location')) {
|
|
|
|
|
$unsplash->trackDownload($downloadLocation);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:40:15 +00:00
|
|
|
$url = (string) data_get($validated, 'url');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
|
|
|
|
|
} catch (RuntimeException) {
|
|
|
|
|
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
|
|
|
|
}
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$media = $workspace->media()->create([
|
2026-04-15 13:20:37 +00:00
|
|
|
'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,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
return new MediaResource($media);
|
2026-04-15 13:20:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function destroy(Request $request, Media $media): RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
if ($media->mediable_type !== $workspace->getMorphClass() || $media->mediable_id !== $workspace->id) {
|
|
|
|
|
abort(SymfonyResponse::HTTP_FORBIDDEN);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$media->delete();
|
|
|
|
|
|
|
|
|
|
return back();
|
|
|
|
|
}
|
|
|
|
|
}
|