This commit is contained in:
Thomas Peterson 2025-07-31 12:13:30 +02:00
parent 5eb85fe090
commit e1192ee506
30 changed files with 876 additions and 97 deletions

View File

@ -5,14 +5,15 @@ declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', ['secret' => '%env(APP_SECRET)%',
'csrf_protection' => true,
'session' => [
'handler_id' => null,
'cookie_secure' => false,
'cookie_httponly' => true,
'cookie_samesite' => 'lax',
],
'php_errors' => ['log' => true]
$containerConfigurator->extension('framework', [
'secret' => '%env(APP_SECRET)%',
'csrf_protection' => true,
'session' => [
'handler_id' => null,
'cookie_secure' => false,
'cookie_httponly' => true,
'cookie_samesite' => 'lax',
],
'php_errors' => ['log' => true],
]);
};

View File

@ -0,0 +1,79 @@
<?php
namespace PSC\Shop\MediaBundle\Api;
use Doctrine\ODM\MongoDB\DocumentManager;
use MongoDB\BSON\ObjectId;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes\JsonContent;
use OpenApi\Attributes\Response;
use OpenApi\Attributes\Tag;
use PSC\Shop\MediaBundle\Document\Folder;
use PSC\Shop\MediaBundle\Dto\Folder\All as PSCAll;
use PSC\Shop\MediaBundle\Model\Folder as PSCFolder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class All extends AbstractController
{
public function __construct(
private readonly DocumentManager $dm,
) {}
#[Response(
response: 200,
description: 'get all media',
content: new JsonContent(ref: new Model(type: PSCAll::class)),
)]
#[Route(path: '/media/all{:uuid}', methods: ['GET'])]
#[Tag('Media')]
#[IsGranted('ROLE_ADMIN')]
#[Security(name: 'Bearer')]
public function all()
{
$folders = $this->dm
->getRepository(Folder::class)
->createQueryBuilder('folder')
->field('parent_id')
->exists(false)
->sort('title', 'ASC')
->getQuery()
->execute();
$output = new PSCAll();
foreach ($folders as $folder) {
$f = new PSCFolder();
$f->setTitle($folder->getTitle());
$f->setUuid($folder->getId());
$this->getSubFolder($f);
$output->data[] = $f;
}
return $this->json($output);
}
private function getSubFolder(PSCFolder $f): void
{
$folders = $this->dm
->getRepository(Folder::class)
->createQueryBuilder('folder')
->field('parent_id')
->equals(new ObjectId($f->getUuid()))
->sort('title', 'ASC')
->getQuery()
->execute();
$tmp = [];
foreach ($folders as $folder) {
$s = new PSCFolder();
$s->setTitle($folder->getTitle());
$s->setUuid($folder->getId());
$tmp[] = $s;
}
$f->setSubFolders($tmp);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace PSC\Shop\MediaBundle\Api;
use Doctrine\ODM\MongoDB\DocumentManager;
use Knp\Component\Pager\PaginatorInterface;
use MongoDB\BSON\ObjectId;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes\JsonContent;
use OpenApi\Attributes\Response;
use OpenApi\Attributes\Tag;
use PSC\Shop\MediaBundle\Document\Media;
use PSC\Shop\MediaBundle\Dto\Media\Folder;
use PSC\Shop\MediaBundle\Model\Media as PSCMedia;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class AllFolderPage extends AbstractController
{
public function __construct(
private readonly DocumentManager $dm,
private readonly PaginatorInterface $paginator,
) {}
#[Response(
response: 200,
description: 'get all media in folder',
content: new JsonContent(ref: new Model(type: Folder::class)),
)]
#[Route(path: '/folder/{uuid}/page/{page}/{max}', methods: ['GET'])]
#[Tag('Media')]
#[IsGranted('ROLE_ADMIN')]
#[Security(name: 'Bearer')]
public function all(string $uuid, int $page = 1, int $max = 12)
{
$qb = $this->dm
->getRepository(Media::class)
->createQueryBuilder('media')
->field('folder.$id')
->equals(new ObjectId($uuid))
->sort('title', 'ASC');
$pagination = $this->paginator->paginate($query = $qb->getQuery(), $page, $max);
$output = new Folder();
$output->count = $pagination->getTotalItemCount();
$output->currentPage = $pagination->getCurrentPageNumber();
$output->lastPage = ceil($output->count / $max);
foreach ($pagination->getItems() as $media) {
$f = new PSCMedia();
$f->setTitle($media->getTitle());
$f->setUrl($media->getUrl());
$output->data[] = $f;
}
return $this->json($output);
}
}

View File

@ -35,6 +35,9 @@ class Add extends AbstractController
$cat = new PSCFolder();
$cat->setTitle($data->title);
$cat->setIcon('fa-file');
if ($data->parentUuid) {
$cat->setParentId($data->parentUuid);
}
$this->dm->persist($cat);
$this->dm->flush();

View File

@ -3,6 +3,7 @@
namespace PSC\Shop\MediaBundle\Api\Folder;
use Doctrine\ODM\MongoDB\DocumentManager;
use MongoDB\BSON\ObjectId;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes\JsonContent;
@ -47,9 +48,33 @@ class All extends AbstractController
$f = new PSCFolder();
$f->setTitle($folder->getTitle());
$f->setUuid($folder->getId());
$this->getSubFolder($f);
$output->data[] = $f;
}
return $this->json($output);
}
private function getSubFolder(PSCFolder $f): void
{
$folders = $this->dm
->getRepository(Folder::class)
->createQueryBuilder('folder')
->field('parent_id')
->equals(new ObjectId($f->getUuid()))
->sort('title', 'ASC')
->getQuery()
->execute();
$tmp = [];
foreach ($folders as $folder) {
$s = new PSCFolder();
$s->setTitle($folder->getTitle());
$s->setUuid($folder->getId());
$this->getSubFolder($s);
$tmp[] = $s;
}
$f->setSubFolders($tmp);
}
}

View File

@ -3,66 +3,70 @@
namespace PSC\Shop\MediaBundle\Api;
use Doctrine\ODM\MongoDB\DocumentManager;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security;
use OpenApi\Annotations as OA;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes\MediaType;
use OpenApi\Attributes\Property;
use OpenApi\Attributes\RequestBody;
use OpenApi\Attributes\Response;
use OpenApi\Attributes\Schema;
use OpenApi\Attributes\Tag;
use PSC\Shop\MediaBundle\Document\Media;
use PSC\Shop\MediaBundle\Helper\MediaManager;
use PSC\Shop\MediaBundle\Model\Media as MediaModel;
use PSC\System\SettingsBundle\Service\Shop;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class Upload extends AbstractController
{
/**
* create media
*
* @OA\Response(
* response=200,
* description="media",
* @OA\JsonContent(ref=@Model(type=\PSC\Shop\MediaBundle\Model\Media::class))
* )
* @OA\RequestBody(
* description="This is a request body",
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* @OA\Property(
* description="folder of file",
* property="folder",
* type="string"
* ),
* @OA\Property(
* description="Binary content of file",
* property="file",
* type="string",
* format="binary",
* ),
* required={"file", "folder"}
* )
* )
* )
* @OA\Tag(name="Media")
* @IsGranted("ROLE_USER")
* @Security(name="ApiKeyAuth")
* @Security(name="Bearer")
*/
#[RequestBody(description: 'file and folder', content: [
new MediaType('multipart/form-data', new Schema(
properties: [new Property(
property: 'file',
format: 'binary',
type: 'file',
description: 'media file',
), new Property(
property: 'folder',
description: 'folder',
)],
required: ['file', 'folder'],
)),
])]
#[Response(response: 200, description: 'add upload to folder', ref: MediaModel::class)]
#[Response(response: 400, description: 'bad request no folder or folder not exists')]
#[Route(path: '/create', methods: ['POST'])]
#[Tag(name: 'Media')]
#[IsGranted('ROLE_USER')]
#[Security(name: 'Bearer')]
#[Security(name: 'ApiKeyAuth')]
public function create(
MediaManager $mediaManager,
Shop $shopService,
DocumentManager $documentManager,
Request $req,
): JsonResponse {
if (!$req->get('folder', false)) {
return $this->json(
data: ['error' => 'folder not provided'],
status: JsonResponse::HTTP_BAD_REQUEST,
);
}
$selectedShop = $shopService->getShopByDomain();
$selectedFolder = $documentManager
->getRepository('PSC\Shop\MediaBundle\Document\Folder')
->findOneBy(['id' => $req->get('folder')]);
if (!$selectedFolder) {
return $this->json(
data: ['error' => 'folder not found'],
status: JsonResponse::HTTP_BAD_REQUEST,
);
}
$handler = $mediaManager->getHandlerForType('pdf');
$media = new Media();
$helper = $handler->getFormHelper($media);

View File

@ -6,9 +6,11 @@ use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes\Items;
use OpenApi\Attributes\Property;
use PSC\Shop\MediaBundle\Model\Folder;
use Symfony\Component\Serializer\Attribute\MaxDepth;
final class All
{
#[Property(type: 'array', items: new Items(ref: new Model(type: Folder::class)))]
#[MaxDepth(4)]
public array $data;
}

View File

@ -8,4 +8,12 @@ final class Input
{
#[Property(type: 'string')]
public string $title;
#[Property(type: 'string')]
public null|string $parentUuid;
public function __construct()
{
$this->parentUuid = null;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace PSC\Shop\MediaBundle\Dto\Media;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes\Items;
use OpenApi\Attributes\Property;
use PSC\Shop\MediaBundle\Model\Media;
use Symfony\Component\Serializer\Attribute\MaxDepth;
final class Folder
{
#[Property(type: 'array', items: new Items(ref: new Model(type: Media::class)))]
#[MaxDepth(4)]
public array $data;
#[Property(type: 'integer')]
public int $count;
#[Property(type: 'integer')]
public int $lastPage;
#[Property(type: 'integer')]
public int $currentPage;
}

View File

@ -4,6 +4,7 @@ namespace PSC\Shop\MediaBundle\Model;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\Serializer\Attribute\MaxDepth;
class Folder
{
@ -13,7 +14,7 @@ class Folder
#[OA\Property(type: 'string')]
private string $uuid = '';
#[OA\Property(type: 'array', items: new OA\Items(ref: new Model(type: Folder::class)))]
#[MaxDepth(4), OA\Property(type: 'array', items: new OA\Items(ref: new Model(type: Folder::class)))]
private array $subFolders = [];
public function getTitle(): string

View File

@ -0,0 +1,83 @@
<?php
namespace App\Tests\PSC\Shop\Media\Api;
use Faker\Factory;
use Faker\Generator;
use PSC\Shop\ContactBundle\Repository\ContactRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Tests\RefreshDatabaseTrait;
class CompleteTest extends WebTestCase
{
use RefreshDatabaseTrait;
private Generator $faker;
private $client;
private $subFolderUuid;
public function setUp(): void
{
$_SERVER['SERVER_NAME'] = 'localhost';
$this->faker = Factory::create(locale: 'de_DE');
$this->client = static::createClient();
$userRepository = static::getContainer()->get(ContactRepository::class);
$testUser = $userRepository->loadUserByUsername('admin@shop.de');
$this->client->loginUser($testUser, 'api');
$name = $this->faker->slug();
$this->client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => $name,
],
[],
);
$data = json_decode($this->client->getResponse()->getContent(), true);
$subName = $this->faker->slug();
$this->client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => $subName,
'parentUuid' => $data['uuid'],
],
[],
);
$subData = json_decode($this->client->getResponse()->getContent(), true);
$this->subFolderUuid = $subData['uuid'];
}
public function testUploadMediaToSubFolder(): void
{
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny.jpg', 'kenney.jpg');
$this->client->request(
'POST',
'/api/media/create',
['folder' => $this->subFolderUuid],
[
'file' => $uploadedFile,
],
);
$media = json_decode($this->client->getResponse()->getContent(), true);
self::assertSame('kenney.jpg', $media['title']);
$this->client->request('GET', sprintf('/api/media/folder/%s/page/%s', $this->subFolderUuid, 1), [], []);
$this->assertResponseIsSuccessful();
$media = json_decode($this->client->getResponse()->getContent(), true);
self::assertCount(1, $media['data']);
}
}

View File

@ -45,6 +45,57 @@ class FolderTest extends WebTestCase
self::assertSame($name, $data['title']);
}
public function testCreateFolderWithSubFolders(): void
{
$client = static::createClient();
$userRepository = static::getContainer()->get(ContactRepository::class);
$testUser = $userRepository->loadUserByUsername('admin@shop.de');
$client->loginUser($testUser, 'api');
$name = $this->faker->slug();
$client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => $name,
],
[],
);
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame($name, $data['title']);
$subName = $this->faker->slug();
$client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => $subName,
'parentUuid' => $data['uuid'],
],
[],
);
$this->assertResponseIsSuccessful();
$subData = json_decode($client->getResponse()->getContent(), true);
self::assertSame($subName, $subData['title']);
$client->jsonRequest('GET', '/api/media/folder/all', [], []);
self::assertCount(1, json_decode($client->getResponse()->getContent(), true)['data']);
self::assertCount(1, json_decode($client->getResponse()->getContent(), true)['data'][0]['subFolders']);
self::assertSame($name, json_decode($client->getResponse()->getContent(), true)['data'][0]['title']);
self::assertSame(
$subName,
json_decode($client->getResponse()->getContent(), true)['data'][0]['subFolders'][0]['title'],
);
}
public function testCreateAndGetFolders(): void
{
$client = static::createClient();

View File

@ -22,11 +22,24 @@ class UploadTest extends WebTestCase
$client->loginUser($testUser, 'api');
$client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => 'testFolder',
],
[],
);
$folder = json_decode($client->getResponse()->getContent(), true);
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny.jpg', 'kenney.jpg');
$client->request(
'POST',
'/api/media/create',
[],
[
'folder' => $folder['uuid'],
],
[
'file' => $uploadedFile,
],

View File

@ -22,61 +22,80 @@ class UploadVariantTest extends WebTestCase
$client->loginUser($testUser, 'api');
$uploadedFile = new UploadedFile(
__DIR__.'/../../../../kenny.jpg',
'kenney.jpg'
$client->jsonRequest(
'POST',
'/api/media/folder/add',
[
'title' => 'testFolder',
],
[],
);
$folder = json_decode($client->getResponse()->getContent(), true);
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny.jpg', 'kenney.jpg');
$client->request(
'POST',
'/api/media/create',
['folder' => $folder['uuid']],
[
'file' => $uploadedFile,
],
);
$client->request('POST', '/api/media/create', [], [
'file' => $uploadedFile
]);
$media = json_decode($client->getResponse()->getContent(), true);
self::assertSame('kenney.jpg', $media['title']);
self::assertNotEmpty($media['url']);
$uploadedFile = new UploadedFile(
__DIR__.'/../../../../kenny_crop.jpg',
'kenny_crop.jpg'
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny_crop.jpg', 'kenny_crop.jpg');
$client->request(
'POST',
'/api/media/variant/create',
[
'uuid' => $media['uuid'],
'settings' => '1/2',
],
[
'file' => $uploadedFile,
],
);
$client->request('POST', '/api/media/variant/create', [
'uuid' => $media['uuid'],
'settings' => '1/2'
], [
'file' => $uploadedFile
]);
$mediaVariant = json_decode($client->getResponse()->getContent(), true);
self::assertNotEmpty($mediaVariant['variants'][0]['url']);
self::assertSame('kenny-crop.jpg', $mediaVariant['variants'][0]['title']);
self::assertCount(1, $mediaVariant['variants']);
$uploadedFile = new UploadedFile(
__DIR__.'/../../../../kenny_crop1.jpg',
'kenny_crop1.jpg'
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny_crop1.jpg', 'kenny_crop1.jpg');
$client->request(
'POST',
'/api/media/variant/create',
[
'uuid' => $media['uuid'],
'settings' => '1/4',
],
[
'file' => $uploadedFile,
],
);
$client->request('POST', '/api/media/variant/create', [
'uuid' => $media['uuid'],
'settings' => '1/4'
], [
'file' => $uploadedFile
]);
$mediaVariant = json_decode($client->getResponse()->getContent(), true);
self::assertNotEmpty($mediaVariant['variants'][1]['url']);
self::assertSame('kenny-crop1.jpg', $mediaVariant['variants'][1]['title']);
self::assertCount(2, $mediaVariant['variants']);
$uploadedFile = new UploadedFile(
__DIR__.'/../../../../kenny_crop_better.jpg',
'kenny_crop_better.jpg'
$uploadedFile = new UploadedFile(__DIR__ . '/../../../../kenny_crop_better.jpg', 'kenny_crop_better.jpg');
$client->request(
'POST',
'/api/media/variant/create',
[
'uuid' => $media['uuid'],
'settings' => '1/2',
],
[
'file' => $uploadedFile,
],
);
$client->request('POST', '/api/media/variant/create', [
'uuid' => $media['uuid'],
'settings' => '1/2'
], [
'file' => $uploadedFile
]);
$mediaVariant = json_decode($client->getResponse()->getContent(), true);
self::assertNotEmpty($mediaVariant['variants'][1]['url']);

View File

@ -8,13 +8,13 @@
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@tailwindcss/vite": "^4.1.10",
"@vueuse/core": "^13.5.0",
"@vueuse/core": "^13.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ky": "^1.8.1",
"lucide-vue-next": "^0.514.0",
"pinia": "^3.0.3",
"reka-ui": "^2.3.2",
"reka-ui": "^2.4.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"ts-debounce": "^4.0.0",
@ -364,11 +364,11 @@
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
"@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="],
"@vueuse/core": ["@vueuse/core@13.6.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.6.0", "@vueuse/shared": "13.6.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A=="],
"@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="],
"@vueuse/metadata": ["@vueuse/metadata@13.6.0", "", {}, "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ=="],
"@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="],
"@vueuse/shared": ["@vueuse/shared@13.6.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg=="],
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
@ -560,7 +560,7 @@
"pretty-ms": ["pretty-ms@9.2.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg=="],
"reka-ui": ["reka-ui@2.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-lCysSCILH2uqShEnt93/qzlXnB7ySvK7scR0Q5C+a2iXwFVzHhvZQsMaSnbQYueoCihx6yyUZTYECepnmKrbRA=="],
"reka-ui": ["reka-ui@2.4.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],

View File

@ -11,16 +11,16 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@tailwindcss/vite": "^4.1.10",
"@vueuse/core": "^13.5.0",
"@vueuse/core": "^13.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ky": "^1.8.1",
"lucide-vue-next": "^0.514.0",
"pinia": "^3.0.3",
"reka-ui": "^2.3.2",
"reka-ui": "^2.4.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"ts-debounce": "^4.0.0",

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { Button } from '../../../components/ui/button';
interface Folder {
uuid: string;
title: string;
subFolders?: Folder[];
}
defineProps<{
folders: Folder[],
selectedFolderId?: string | null
}>();
const emit = defineEmits(['select-folder']);
const selectFolder = (folderId: string) => {
emit('select-folder', folderId);
};
</script>
<template>
<ul class="w-full">
<li v-for="folder in folders" :key="folder.uuid">
<Button
:variant="folder.uuid === selectedFolderId ? 'secondary' : 'ghost'"
@click="selectFolder(folder.uuid)"
class="w-full justify-start"
>
{{ folder.title }}
</Button>
<div v-if="folder.subFolders && folder.subFolders.length > 0" class="ml-4">
<FolderTree
:folders="folder.subFolders"
:selected-folder-id="selectedFolderId"
@select-folder="selectFolder"
/>
</div>
</li>
</ul>
</template>

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { fetchMediaFolders, fetchMediaByFolder } from '../../../lib/api';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../../../components/ui/resizable';
import {
Pagination,
PaginationEllipsis,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '../../../components/ui/pagination'
import { Button } from '../../../components/ui/button'
import FolderTree from './FolderTree.vue';
interface Folder {
uuid: string;
title: string;
subFolders?: Folder[];
}
interface Media {
uuid: string;
url: string;
name: string;
}
const folders = ref<Folder[]>([]);
const media = ref<Media[]>([]);
const selectedFolder = ref<string | null>(null);
const currentPage = ref(1);
const totalPages = ref(1);
const loadFolders = async () => {
try {
const response: any = await fetchMediaFolders();
folders.value = response.data;
if (folders.value.length > 0) {
selectFolder(folders.value[0].uuid);
}
} catch (error) {
console.error('Failed to fetch folders', error);
}
};
const loadMedia = async (folderId: string, page: number = 1) => {
try {
const response: any = await fetchMediaByFolder(folderId, page);
media.value = response.data;
currentPage.value = response.currentPage;
totalPages.value = response.lastPage;
} catch (error) {
console.error(`Failed to fetch media for folder ${folderId}`, error);
}
};
const selectFolder = (folderId: string) => {
selectedFolder.value = folderId;
loadMedia(folderId, 1);
};
const onPageChange = (page: number) => {
if (selectedFolder.value) {
loadMedia(selectedFolder.value, page);
}
};
onMounted(() => {
loadFolders();
});
</script>
<template>
<div class="h-[70vh] flex flex-col">
<h1 class="text-2xl font-bold mb-4">Media Browser</h1>
<ResizablePanelGroup direction="horizontal" class="flex-grow rounded-lg border">
<ResizablePanel :default-size="25">
<div class="flex h-full items-start justify-center p-6">
<FolderTree :folders="folders" :selected-folder-id="selectedFolder" @select-folder="selectFolder" />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel :default-size="75">
<div class="flex flex-col h-full p-6">
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 flex-grow">
<div v-for="item in media" :key="item.uuid" class="aspect-square bg-gray-100 rounded-lg overflow-hidden">
<img :src="item.url" :alt="item.name" class="w-full h-full object-cover" />
</div>
</div>
<div class="mt-4 flex justify-center">
<Pagination v-if="totalPages > 1" :total="totalPages" :sibling-count="1" show-edges :default-page="currentPage" @update:page="onPageChange">
<PaginationContent v-slot="{ items }" class="flex items-center gap-1">
<PaginationPrevious />
<template v-for="(page, index) in items">
<PaginationItem v-if="page.type === 'page'" :key="index" :value="page.value" as-child>
<Button class="w-10 h-10 p-0" :variant="page.value === currentPage ? 'default' : 'outline'">
{{ page.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else :key="page.type" :index="index" />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</template>

View File

@ -3,8 +3,10 @@ import MediaElement from '../../../model/MediaElement';
import { computed } from 'vue';
import { Input } from '../../../components/ui/input'
import { Button } from '../../../components/ui/button'
import { fetchMediaDirectories, uploadFile } from '../../../lib/api';
import { fetchMediaFolders, uploadFile } from '../../../lib/api';
import { onMounted, ref } from 'vue';
import { Dialog, DialogContent, DialogTrigger } from '../../../components/ui/dialog'
import MediaBrowser from '../media/MediaBrowser.vue';
const props = defineProps({
modelValue: MediaElement
@ -52,7 +54,7 @@ const onFileChange = (event: Event) => {
onMounted(async () => {
try {
let response: any = await fetchMediaDirectories()
let response: any = await fetchMediaFolders()
directories.value = response.data
if (response.data.length > 0) {
selectedDirectory.value = directories.value[0].uuid;
@ -87,7 +89,14 @@ const handleFile = async (file: File) => {
<div>
<label>{{ $t('id') }}</label>
<Input v-model="theModel!.id" />
<Button class="my-2 w-full">Mediabrowser</Button>
<Dialog>
<DialogTrigger as-child>
<Button class="my-2 w-full">Mediabrowser</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-5xl max-h-[80vh] overflow-y-auto">
<MediaBrowser />
</DialogContent>
</Dialog>
<div
class="flex items-center justify-center w-full"
@dragover.prevent="onDragOver"

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationRoot, type PaginationRootEmits, type PaginationRootProps, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationList, type PaginationListProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationListProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>

View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { MoreHorizontal } from 'lucide-vue-next'
import { PaginationEllipsis, type PaginationEllipsisProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationFirstProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeftIcon } from 'lucide-vue-next'
import { PaginationFirst, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationListItem, type PaginationListItemProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
isActive?: boolean
}>(), {
size: 'icon',
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationLastProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import { PaginationLast, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationNextProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import { PaginationNext, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationPrevProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeftIcon } from 'lucide-vue-next'
import { PaginationPrev, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>

View File

@ -0,0 +1,8 @@
export { default as Pagination } from './Pagination.vue'
export { default as PaginationContent } from './PaginationContent.vue'
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
export { default as PaginationFirst } from './PaginationFirst.vue'
export { default as PaginationItem } from './PaginationItem.vue'
export { default as PaginationLast } from './PaginationLast.vue'
export { default as PaginationNext } from './PaginationNext.vue'
export { default as PaginationPrevious } from './PaginationPrevious.vue'

View File

@ -124,7 +124,7 @@ export const uploadFile = async (file: File, folder: string, onProgress: (progre
}
};
export const fetchMediaDirectories = async () => {
export const fetchMediaFolders = async () => {
try {
const response = await api.get('api/media/folder/all');
return await response.json();
@ -133,4 +133,15 @@ export const fetchMediaDirectories = async () => {
throw error;
}
};
export const fetchMediaByFolder = async (folderId: string, page: number = 1) => {
try {
const response = await api.get(`api/media/folder/${folderId}/page/${page}/12`);
return await response.json();
} catch (error) {
console.error(`Error fetching media for folder ${folderId}:`, error);
throw error;
}
};
export default api;

View File

@ -33,7 +33,7 @@ export default defineConfig({
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Authorization', 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTM4ODEwMjUsImV4cCI6MTc1Mzg4NDYyNSwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU0hPUF9PUEVSQVRPUiIsIlJPTEVfVVNFUiIsIlJPTEVfVVNFUiIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9FZGl0IiwiUk9MRV9QU0NfQ29sbGVjdF9Db250YWN0X0FkZCIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9EZWxldGUiLCJST0xFX1BTQ19Db2xsZWN0X0NvbnRhY3RfTG9jayIsIlJPTEVfUFNDX1IyX1NlbmRjbG91ZF9TaG93Il0sInVpZCI6MX0.T0JzZ8qoZOqOOGtHXtuGvY5xAwwWGSAHh9MXgGJlyngjsmA_RWUnL8wVW3Ah827sJQNL2W1JPEz_f9CuCvfCSVjoLml9T_n5N5xtB98wVdcksgh3PvtFrYs-NVUat9QlwJ54F0cUXIkyuimEc2op0Y2sSC9Pyw6d9m8WtYrPX657uXZ3U8KcX8FIqCMWpjzIsbLK2QemT-fgkJpVnSdvo8nFvComZ1yDPvj5Zl_m0NEF3CybYdxOOwz42egI297BM_qGp6_cZLeSrl2EwphzeFqPo8-q9wmuHj33HZozlwjlu_Uvp4vq0Jr98-WZkIUwK7706E1t_TdSc46nONyxVA');
proxyReq.setHeader('Authorization', 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTM5NTU4MzMsImV4cCI6MTc1Mzk1OTQzMywicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU0hPUF9PUEVSQVRPUiIsIlJPTEVfVVNFUiIsIlJPTEVfVVNFUiIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9FZGl0IiwiUk9MRV9QU0NfQ29sbGVjdF9Db250YWN0X0FkZCIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9EZWxldGUiLCJST0xFX1BTQ19Db2xsZWN0X0NvbnRhY3RfTG9jayIsIlJPTEVfUFNDX1IyX1NlbmRjbG91ZF9TaG93Il0sInVpZCI6MX0.rUlkpFBa-RKQAxSa6e0UmggiqPlDj-Mz4I56T-kTOT5j5veI3JFYHOvptbeEjITPdmHM_Dm86CpEuIkezQC1UBsuzGkSYLQRy8Cb_YnNEEbaoCsg28q60Wu_dfBbDEFRjXnbEVSrwbfawRMq4FUTuky7r8qstORRhL1bPtfL3cN_lPEruffSreuvUReM7inkjOHU4utGuVhMm1D3oK1p398YmZACmQfiYLYOM6c07Isop5IZ0Mfq0LZ_BEVptQi7Thb4nAHiEUZfyBqhLrrWxmUFcpF2Mp7-_dZZeiA9w2ZYZBA-sGMAF0EwCDU1_WK-o0BDZDk-qKMj4zrFHe8TEg');
});
},
},