From 18ec6169b3fc28d38cf7c2d1b723643f189c0dea Mon Sep 17 00:00:00 2001 From: obelix58143 <88147701+obelix58143@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:24:18 +0200 Subject: [PATCH 1/6] feat(assets): read a WebDAV share as a media source Teams that keep their photos and videos on a WebDAV share - Nextcloud, ownCloud or anything else speaking the protocol - currently have to download a file and upload it again to post it. The service browses one directory level at a time and streams a single file onto disk. It is read-only: there is no PUT, MKCOL or DELETE anywhere in it. Paths coming back from the client are normalised rather than rejected, so `..`, a leading slash or a backslash land on the root of the share instead of somewhere else on the server. Size is settled before the body is fetched: the share is asked over HEAD, and `WEBDAV_MAX_IMPORT_MB` decides. What actually landed on disk is checked afterwards as well, because a HEAD answer is the share's word rather than a guarantee - and because the body is streamed, neither path asks the process to hold a file whole. The endpoint is configured by the operator, the way S3 or R2 are, which is why it may point at a host on the local network that the SSRF guard would refuse for a user-supplied URL. Co-Authored-By: Claude Opus 5 --- .env.example | 13 ++ app/Services/WebdavService.php | 254 +++++++++++++++++++++++++++++++++ config/services.php | 13 ++ 3 files changed, 280 insertions(+) create mode 100644 app/Services/WebdavService.php diff --git a/.env.example b/.env.example index b1ff91de7..c9934be83 100644 --- a/.env.example +++ b/.env.example @@ -285,6 +285,19 @@ NIGHTWATCH_TOKEN= # TELEGRAM_ENABLED=true # Media Services +# Read-only WebDAV share offered as a media source in the editor (Nextcloud, +# ownCloud, ...). WEBDAV_URL points at the collection to browse, for example +# https://cloud.example.com/remote.php/dav/files/ +WEBDAV_URL= +WEBDAV_USERNAME= +WEBDAV_PASSWORD= +# Optional folder inside the share to confine browsing to. +WEBDAV_ROOT= +# Name of the tab in the media picker. +WEBDAV_LABEL="Files" +# Largest single file that may be imported, in megabytes. +WEBDAV_MAX_IMPORT_MB=512 + UNSPLASH_ACCESS_KEY= UNSPLASH_SECRET_KEY= GIPHY_API_KEY= diff --git a/app/Services/WebdavService.php b/app/Services/WebdavService.php new file mode 100644 index 000000000..97831ddff --- /dev/null +++ b/app/Services/WebdavService.php @@ -0,0 +1,254 @@ +>} + */ + public function list(string $path = ''): array + { + $path = $this->normalize($path); + + $response = $this->request() + ->withHeaders(['Depth' => '1']) + ->send('PROPFIND', $this->absoluteUrl($path), [ + 'body' => $this->propfindBody(), + 'headers' => ['Content-Type' => 'application/xml'], + ]); + + if ($response->failed()) { + Log::warning('WebDAV listing failed', [ + 'path' => $path, + 'status' => $response->status(), + ]); + + throw new RuntimeException('The WebDAV share could not be listed.'); + } + + return [ + 'path' => $path, + 'parent' => $path === '' ? null : $this->parentOf($path), + 'entries' => $this->parseListing($response->body(), $path), + ]; + } + + /** + * Downloads one file into a temporary path and returns it. The caller owns + * the file and is responsible for removing it. + */ + public function download(string $path): string + { + $path = $this->normalize($path); + + if ($path === '') { + throw new RuntimeException('No file was given.'); + } + + $url = $this->absoluteUrl($path); + + // Ask how big it is first: a share holds film rushes as readily as + // snapshots, and one of those should be refused before it is pulled + // across the network rather than after. + $head = $this->request()->head($url); + + if ($head->successful() && (int) $head->header('Content-Length') > $this->maxImportBytes()) { + throw new InvalidArgumentException('The file is too large to import.'); + } + + $temporary = tempnam(sys_get_temp_dir(), 'webdav'); + + if ($temporary === false) { + throw new RuntimeException('The downloaded file could not be stored.'); + } + + // Streamed onto disk instead of held in memory - the share decides how + // large a file is, so the process must not have to hold one whole. + $response = $this->request()->timeout(120)->sink($temporary)->get($url); + + if ($response->failed()) { + @unlink($temporary); + + Log::warning('WebDAV download failed', [ + 'path' => $path, + 'status' => $response->status(), + ]); + + throw new RuntimeException('The file could not be downloaded from the WebDAV share.'); + } + + clearstatcache(true, $temporary); + + // A share that answers no HEAD, or understates the length, lands here. + if (filesize($temporary) > $this->maxImportBytes()) { + @unlink($temporary); + + throw new InvalidArgumentException('The file is too large to import.'); + } + + return $temporary; + } + + /** + * Keeps a path inside the configured root. Traversal, absolute paths and + * backslashes are dropped rather than rejected, so a mangled path lands on + * the root instead of somewhere else on the server. + */ + public function normalize(string $path): string + { + $path = str_replace('\\', '/', $path); + + $segments = array_filter( + explode('/', $path), + static fn (string $segment): bool => $segment !== '' && $segment !== '.' && $segment !== '..' + ); + + return implode('/', $segments); + } + + private function parentOf(string $path): string + { + $segments = explode('/', $path); + array_pop($segments); + + return implode('/', $segments); + } + + private function request(): PendingRequest + { + return Http::withBasicAuth( + (string) config('services.webdav.username'), + (string) config('services.webdav.password'), + )->timeout(30); + } + + private function absoluteUrl(string $path): string + { + $base = rtrim((string) config('services.webdav.url'), '/'); + $root = trim((string) config('services.webdav.root', ''), '/'); + + $full = implode('/', array_filter([$base, $root, $path === '' ? null : $path])); + + // Each segment is encoded on its own so that slashes stay separators + // while spaces and non-ASCII names survive the round-trip. + $prefix = $base.($root === '' ? '' : '/'.$root); + $relative = Str::after($full, $prefix); + + return $prefix.implode('/', array_map('rawurlencode', array_filter(explode('/', $relative)))); + } + + private function propfindBody(): string + { + return '' + .'' + .'' + .'' + .''; + } + + /** + * @return array> + */ + private function parseListing(string $xml, string $currentPath): array + { + $previous = libxml_use_internal_errors(true); + $document = simplexml_load_string($xml); + libxml_use_internal_errors($previous); + + if ($document === false) { + throw new RuntimeException('The WebDAV share returned an unreadable listing.'); + } + + $self = rtrim($this->hrefPathOf($currentPath), '/'); + $entries = []; + + // Properties live in the DAV namespace, so every step goes through + // children('DAV:') - plain property access does not cross namespaces + // and silently yields nothing. + foreach ($document->children('DAV:')->response as $response) { + $href = rawurldecode((string) $response->children('DAV:')->href); + + // The first entry of a PROPFIND is the collection itself. + if (rtrim($href, '/') === $self) { + continue; + } + + $name = basename(rtrim($href, '/')); + + if ($name === '') { + continue; + } + + $properties = $response->children('DAV:')->propstat->children('DAV:')->prop ?? null; + + if ($properties === null) { + continue; + } + + $isDirectory = isset($properties->resourcetype->children('DAV:')->collection); + + $entries[] = [ + 'name' => (string) ($properties->displayname ?: $name), + 'path' => trim(($currentPath === '' ? '' : $currentPath.'/').$name, '/'), + 'is_directory' => $isDirectory, + 'size' => $isDirectory ? null : (int) $properties->getcontentlength, + 'mime_type' => $isDirectory ? null : ((string) $properties->getcontenttype ?: null), + 'modified_at' => (string) $properties->getlastmodified ?: null, + ]; + } + + usort($entries, static function (array $a, array $b): int { + if ($a['is_directory'] !== $b['is_directory']) { + return $a['is_directory'] ? -1 : 1; + } + + return strnatcasecmp((string) $a['name'], (string) $b['name']); + }); + + return $entries; + } + + private function hrefPathOf(string $path): string + { + return (string) parse_url($this->absoluteUrl($path), PHP_URL_PATH); + } +} diff --git a/config/services.php b/config/services.php index 4b5d5690f..7b9910a8a 100644 --- a/config/services.php +++ b/config/services.php @@ -127,6 +127,19 @@ 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), ], + // Read-only WebDAV share (Nextcloud, ownCloud, ...) offered as a media + // source in the editor, so files a team already keeps there do not have to + // be downloaded and uploaded again. + 'webdav' => [ + 'url' => env('WEBDAV_URL'), + 'username' => env('WEBDAV_USERNAME'), + 'password' => env('WEBDAV_PASSWORD'), + // Optional folder inside the share to confine browsing to. + 'root' => env('WEBDAV_ROOT', ''), + 'label' => env('WEBDAV_LABEL', 'Files'), + 'max_import_bytes' => ((int) env('WEBDAV_MAX_IMPORT_MB', 512)) * 1024 * 1024, + ], + 'unsplash' => [ 'access_key' => env('UNSPLASH_ACCESS_KEY'), 'secret_key' => env('UNSPLASH_SECRET_KEY'), From 43c9c2e8dcf02a26e6baea88655b7f2a1dd2555f Mon Sep 17 00:00:00 2001 From: obelix58143 <88147701+obelix58143@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:24:18 +0200 Subject: [PATCH 2/6] feat(assets): browse and import from the share over HTTP Both endpoints sit behind the same `createPost` check the rest of the asset routes use, and both 404 while no share is configured, so an instance without one behaves as if the feature did not exist. Importing copies each file into the workspace's own library instead of linking to it, so a post keeps the media it was scheduled with even after the share changes. A share holds spreadsheets and archives too: a file the editor cannot use is reported as a refusal, and one bad pick does not discard the rest of a selection - what came through is returned alongside the names that did not. Co-Authored-By: Claude Opus 5 --- app/Enums/Media/Source.php | 3 +- app/Http/Controllers/App/WebdavController.php | 90 +++++++++++++++++++ .../Middleware/App/HandleInertiaRequests.php | 3 + .../App/Asset/ImportWebdavRequest.php | 21 +++++ routes/app.php | 3 + 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/App/WebdavController.php create mode 100644 app/Http/Requests/App/Asset/ImportWebdavRequest.php diff --git a/app/Enums/Media/Source.php b/app/Enums/Media/Source.php index 98d44d646..424375d05 100644 --- a/app/Enums/Media/Source.php +++ b/app/Enums/Media/Source.php @@ -8,7 +8,7 @@ * Origin of a media attachment on a post. `null`/absent means "uploaded by * the user" (legacy/unknown). The enum is forward-compatible so the regen * UI can decide on a per-source basis what actions are available (only `Ai` - * exposes a "regenerate" button today, but `Unsplash`/`Giphy` may surface + * exposes a "regenerate" button today, but `Unsplash`/`Giphy`/`Webdav` may surface * "fetch alternate" or attribution UIs later). */ enum Source: string @@ -16,4 +16,5 @@ enum Source: string case Ai = 'ai'; case Unsplash = 'unsplash'; case Giphy = 'giphy'; + case Webdav = 'webdav'; } diff --git a/app/Http/Controllers/App/WebdavController.php b/app/Http/Controllers/App/WebdavController.php new file mode 100644 index 000000000..c8f688ce7 --- /dev/null +++ b/app/Http/Controllers/App/WebdavController.php @@ -0,0 +1,90 @@ +user()->currentWorkspace; + + $this->authorize('createPost', $workspace); + + abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND); + + try { + return response()->json($webdav->list((string) $request->string('path'))); + } catch (RuntimeException $e) { + abort(SymfonyResponse::HTTP_BAD_GATEWAY, $e->getMessage()); + } + } + + public function store(ImportWebdavRequest $request, WebdavService $webdav): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('createPost', $workspace); + + abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND); + + $imported = []; + $failed = []; + + foreach ($request->validated('paths') as $path) { + $temporary = null; + + try { + $temporary = $webdav->download($path); + + $imported[] = $workspace->addMediaFromPath( + filePath: $temporary, + originalFilename: basename($webdav->normalize($path)), + collection: 'assets', + ); + } catch (InvalidArgumentException|RuntimeException $e) { + // A share holds everything, not just postable media, and one + // unreadable file should not discard the rest of a selection. + $failed[] = [ + 'path' => $path, + 'name' => basename($webdav->normalize($path)), + 'message' => $e->getMessage(), + 'status' => $e instanceof InvalidArgumentException + ? SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY + : SymfonyResponse::HTTP_BAD_GATEWAY, + ]; + } finally { + if ($temporary !== null && file_exists($temporary)) { + @unlink($temporary); + } + } + } + + // Nothing came through: report it as the error it is, using the reason + // of the first file so the message stays specific. + if ($imported === [] && $failed !== []) { + abort($failed[0]['status'], $failed[0]['message']); + } + + return response()->json([ + 'media' => MediaResource::collection($imported), + 'failed' => array_map( + static fn (array $failure): array => [ + 'name' => $failure['name'], + 'message' => $failure['message'], + ], + $failed, + ), + ]); + } +} diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index c3b19ae05..3f922e893 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -13,6 +13,7 @@ use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource; use App\Http\Resources\App\PlanResource; use App\Models\Plan; +use App\Services\WebdavService; use Illuminate\Http\Request; use Inertia\Middleware; @@ -63,6 +64,8 @@ public function share(Request $request): array 'locale' => app()->getLocale(), 'languages' => Locale::options(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), + 'webdavEnabled' => app(WebdavService::class)->enabled(), + 'webdavLabel' => (string) config('services.webdav.label'), 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), diff --git a/app/Http/Requests/App/Asset/ImportWebdavRequest.php b/app/Http/Requests/App/Asset/ImportWebdavRequest.php new file mode 100644 index 000000000..7b95d823b --- /dev/null +++ b/app/Http/Requests/App/Asset/ImportWebdavRequest.php @@ -0,0 +1,21 @@ + + */ + public function rules(): array + { + return [ + 'paths' => ['required', 'array', 'min:1', 'max:20'], + 'paths.*' => ['required', 'string', 'max:1024'], + ]; + } +} diff --git a/routes/app.php b/routes/app.php index f4591254d..bbbe274fc 100644 --- a/routes/app.php +++ b/routes/app.php @@ -25,6 +25,7 @@ use App\Http\Controllers\App\Settings\ProfileController; use App\Http\Controllers\App\Settings\SettingsController; use App\Http\Controllers\App\UnsplashController; +use App\Http\Controllers\App\WebdavController; use App\Http\Controllers\App\WebhookController; use App\Http\Controllers\App\WelcomeController; use App\Http\Controllers\App\WorkspaceController; @@ -238,6 +239,8 @@ Route::delete('assets/{media}', [AssetController::class, 'destroy'])->name('app.assets.destroy'); Route::get('assets/unsplash/search', [UnsplashController::class, 'search'])->name('app.assets.unsplash.search'); Route::get('assets/unsplash/trending', [UnsplashController::class, 'trending'])->name('app.assets.unsplash.trending'); + Route::get('assets/webdav/browse', [WebdavController::class, 'browse'])->name('app.assets.webdav.browse'); + Route::post('assets/webdav/import', [WebdavController::class, 'store'])->name('app.assets.webdav.store'); Route::get('assets/giphy/search', [GiphyController::class, 'search'])->name('app.assets.giphy.search'); Route::get('assets/giphy/trending', [GiphyController::class, 'trending'])->name('app.assets.giphy.trending'); From 4bf9287ce3efecb026fd6d7c410c9316e41ebc64 Mon Sep 17 00:00:00 2001 From: obelix58143 <88147701+obelix58143@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:59:22 +0200 Subject: [PATCH 3/6] feat(assets): add the share as a tab in the media picker The tab carries the operator's label, lists folders before files, walks down into a folder and back up to the configured root, and imports a multi-file selection in one request. It only appears once a share is configured. When part of a selection is refused, the tab names the files that did not make it rather than reporting the whole import as failed. Every string is translated into all sixteen locales, so an instance running in one language does not fall back to English mid-picker. Co-Authored-By: Claude Opus 5 --- lang/ar/assets.php | 10 ++ lang/de/assets.php | 9 + lang/el/assets.php | 10 ++ lang/en/assets.php | 9 + lang/es/assets.php | 10 ++ lang/fr/assets.php | 10 ++ lang/it/assets.php | 10 ++ lang/ja/assets.php | 10 ++ lang/ko/assets.php | 10 ++ lang/nl/assets.php | 10 ++ lang/pl/assets.php | 10 ++ lang/pt-BR/assets.php | 10 ++ lang/ru/assets.php | 10 ++ lang/tr/assets.php | 10 ++ lang/uk/assets.php | 10 ++ lang/zh/assets.php | 10 ++ .../js/components/assets/GalleryBrowser.vue | 154 +++++++++++++++++- resources/js/types/media.ts | 2 +- 18 files changed, 311 insertions(+), 3 deletions(-) diff --git a/lang/ar/assets.php b/lang/ar/assets.php index 1c59d9b03..9f6046462 100644 --- a/lang/ar/assets.php +++ b/lang/ar/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'جرّب كلمة بحث مختلفة.', 'powered_by' => 'مقدَّم من GIPHY', ], + + 'webdav' => [ + 'up' => 'مجلد للأعلى', + 'import' => 'استيراد :count عنصر محدد', + 'loading' => 'جارٍ تحميل المجلد...', + 'empty' => 'هذا المجلد فارغ.', + 'unreachable' => 'تعذّر الوصول إلى المشاركة.', + 'import_failed' => 'تعذّر استيراد هذه الملفات.', + 'import_partial' => '{1} تعذّر استيراد :names.|[2,*] تعذّر استيراد :count ملفات: :names', + ], ]; diff --git a/lang/de/assets.php b/lang/de/assets.php index 9ad7195f3..ddb2224ff 100644 --- a/lang/de/assets.php +++ b/lang/de/assets.php @@ -53,4 +53,13 @@ 'no_results_description' => 'Versuche einen anderen Suchbegriff.', 'powered_by' => 'Bereitgestellt von GIPHY', ], + 'webdav' => [ + 'up' => 'Eine Ebene höher', + 'import' => ':count ausgewählte übernehmen', + 'loading' => 'Ordner wird geladen …', + 'empty' => 'Dieser Ordner ist leer.', + 'unreachable' => 'Die Freigabe ist nicht erreichbar.', + 'import_failed' => 'Diese Dateien konnten nicht übernommen werden.', + 'import_partial' => '{1} :names konnte nicht übernommen werden.|[2,*] :count Dateien konnten nicht übernommen werden: :names', + ], ]; diff --git a/lang/el/assets.php b/lang/el/assets.php index 6cae0e14e..1dfa80c88 100644 --- a/lang/el/assets.php +++ b/lang/el/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Δοκιμάστε διαφορετικό όρο αναζήτησης.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Ένα φάκελο επάνω', + 'import' => 'Εισαγωγή :count επιλεγμένων', + 'loading' => 'Φόρτωση φακέλου...', + 'empty' => 'Αυτός ο φάκελος είναι κενός.', + 'unreachable' => 'Δεν ήταν δυνατή η σύνδεση με τον κοινόχρηστο φάκελο.', + 'import_failed' => 'Δεν ήταν δυνατή η εισαγωγή αυτών των αρχείων.', + 'import_partial' => '{1} Δεν ήταν δυνατή η εισαγωγή του :names.|[2,*] Δεν ήταν δυνατή η εισαγωγή :count αρχείων: :names', + ], ]; diff --git a/lang/en/assets.php b/lang/en/assets.php index 91f2c9502..0512831f2 100644 --- a/lang/en/assets.php +++ b/lang/en/assets.php @@ -51,4 +51,13 @@ 'no_results_description' => 'Try a different search term.', 'powered_by' => 'Powered by GIPHY', ], + 'webdav' => [ + 'up' => 'Up one folder', + 'import' => 'Import :count selected', + 'loading' => 'Loading the folder...', + 'empty' => 'This folder is empty.', + 'unreachable' => 'The share could not be reached.', + 'import_failed' => 'Those files could not be imported.', + 'import_partial' => '{1} :names could not be imported.|[2,*] :count files could not be imported: :names', + ], ]; diff --git a/lang/es/assets.php b/lang/es/assets.php index e99be7bcf..9def6c809 100644 --- a/lang/es/assets.php +++ b/lang/es/assets.php @@ -53,4 +53,14 @@ 'no_results_description' => 'Prueba con otro término de búsqueda.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Subir un nivel', + 'import' => 'Importar :count seleccionados', + 'loading' => 'Cargando la carpeta...', + 'empty' => 'Esta carpeta está vacía.', + 'unreachable' => 'No se pudo acceder al recurso compartido.', + 'import_failed' => 'No se pudieron importar esos archivos.', + 'import_partial' => '{1} No se pudo importar :names.|[2,*] No se pudieron importar :count archivos: :names', + ], ]; diff --git a/lang/fr/assets.php b/lang/fr/assets.php index 9068a3914..afab19e98 100644 --- a/lang/fr/assets.php +++ b/lang/fr/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Essayez un autre terme de recherche.', 'powered_by' => 'Propulsé par GIPHY', ], + + 'webdav' => [ + 'up' => 'Dossier parent', + 'import' => 'Importer :count élément(s)', + 'loading' => 'Chargement du dossier...', + 'empty' => 'Ce dossier est vide.', + 'unreachable' => 'Le partage est inaccessible.', + 'import_failed' => 'Ces fichiers n\'ont pas pu être importés.', + 'import_partial' => '{1} :names n\'a pas pu être importé.|[2,*] :count fichiers n\'ont pas pu être importés : :names', + ], ]; diff --git a/lang/it/assets.php b/lang/it/assets.php index 68ea2c122..e8613ca8d 100644 --- a/lang/it/assets.php +++ b/lang/it/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Prova un altro termine di ricerca.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Cartella superiore', + 'import' => 'Importa :count selezionati', + 'loading' => 'Caricamento della cartella...', + 'empty' => 'Questa cartella è vuota.', + 'unreachable' => 'Impossibile raggiungere la condivisione.', + 'import_failed' => 'Impossibile importare questi file.', + 'import_partial' => '{1} Impossibile importare :names.|[2,*] Impossibile importare :count file: :names', + ], ]; diff --git a/lang/ja/assets.php b/lang/ja/assets.php index 851a90f81..3bd063c77 100644 --- a/lang/ja/assets.php +++ b/lang/ja/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => '別のキーワードで検索してみてください。', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => '一つ上のフォルダー', + 'import' => '選択した :count 件を取り込む', + 'loading' => 'フォルダーを読み込んでいます...', + 'empty' => 'このフォルダーは空です。', + 'unreachable' => '共有に接続できませんでした。', + 'import_failed' => 'これらのファイルを取り込めませんでした。', + 'import_partial' => '{1} :names を取り込めませんでした。|[2,*] :count 件のファイルを取り込めませんでした: :names', + ], ]; diff --git a/lang/ko/assets.php b/lang/ko/assets.php index b22d5cdea..b6b0db86b 100644 --- a/lang/ko/assets.php +++ b/lang/ko/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => '다른 검색어로 시도해 보세요.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => '상위 폴더', + 'import' => '선택한 :count개 가져오기', + 'loading' => '폴더를 불러오는 중...', + 'empty' => '이 폴더는 비어 있습니다.', + 'unreachable' => '공유에 연결할 수 없습니다.', + 'import_failed' => '해당 파일을 가져오지 못했습니다.', + 'import_partial' => '{1} :names을(를) 가져오지 못했습니다.|[2,*] :count개 파일을 가져오지 못했습니다: :names', + ], ]; diff --git a/lang/nl/assets.php b/lang/nl/assets.php index 77e6fcc90..60bd0da21 100644 --- a/lang/nl/assets.php +++ b/lang/nl/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Probeer een andere zoekterm.', 'powered_by' => 'Mogelijk gemaakt door GIPHY', ], + + 'webdav' => [ + 'up' => 'Map omhoog', + 'import' => ':count geselecteerde importeren', + 'loading' => 'Map wordt geladen...', + 'empty' => 'Deze map is leeg.', + 'unreachable' => 'De share is niet bereikbaar.', + 'import_failed' => 'Deze bestanden konden niet worden geïmporteerd.', + 'import_partial' => '{1} :names kon niet worden geïmporteerd.|[2,*] :count bestanden konden niet worden geïmporteerd: :names', + ], ]; diff --git a/lang/pl/assets.php b/lang/pl/assets.php index c3a6beab3..812765302 100644 --- a/lang/pl/assets.php +++ b/lang/pl/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Spróbuj innego wyszukiwanego hasła.', 'powered_by' => 'Napędzane przez GIPHY', ], + + 'webdav' => [ + 'up' => 'Folder wyżej', + 'import' => 'Importuj zaznaczone (:count)', + 'loading' => 'Wczytywanie folderu...', + 'empty' => 'Ten folder jest pusty.', + 'unreachable' => 'Nie można połączyć się z udziałem.', + 'import_failed' => 'Nie udało się zaimportować tych plików.', + 'import_partial' => '{1} Nie udało się zaimportować :names.|[2,*] Nie udało się zaimportować :count plików: :names', + ], ]; diff --git a/lang/pt-BR/assets.php b/lang/pt-BR/assets.php index a402f9768..956e39edc 100644 --- a/lang/pt-BR/assets.php +++ b/lang/pt-BR/assets.php @@ -53,4 +53,14 @@ 'no_results_description' => 'Tente outro termo de busca.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Pasta acima', + 'import' => 'Importar :count selecionados', + 'loading' => 'Carregando a pasta...', + 'empty' => 'Esta pasta está vazia.', + 'unreachable' => 'Não foi possível acessar o compartilhamento.', + 'import_failed' => 'Não foi possível importar esses arquivos.', + 'import_partial' => '{1} Não foi possível importar :names.|[2,*] Não foi possível importar :count arquivos: :names', + ], ]; diff --git a/lang/ru/assets.php b/lang/ru/assets.php index 0413b7092..58127fe5f 100644 --- a/lang/ru/assets.php +++ b/lang/ru/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Попробуйте другой запрос.', 'powered_by' => 'При поддержке GIPHY', ], + + 'webdav' => [ + 'up' => 'На папку выше', + 'import' => 'Импортировать выбранное (:count)', + 'loading' => 'Загрузка папки...', + 'empty' => 'Эта папка пуста.', + 'unreachable' => 'Не удалось подключиться к общему ресурсу.', + 'import_failed' => 'Не удалось импортировать эти файлы.', + 'import_partial' => '{1} Не удалось импортировать :names.|[2,*] Не удалось импортировать файлов: :count — :names', + ], ]; diff --git a/lang/tr/assets.php b/lang/tr/assets.php index 45578fc81..10347a431 100644 --- a/lang/tr/assets.php +++ b/lang/tr/assets.php @@ -53,4 +53,14 @@ 'no_results_description' => 'Farklı bir arama terimi deneyin.', 'powered_by' => 'GIPHY tarafından desteklenmektedir', ], + + 'webdav' => [ + 'up' => 'Bir üst klasör', + 'import' => 'Seçilen :count öğeyi içe aktar', + 'loading' => 'Klasör yükleniyor...', + 'empty' => 'Bu klasör boş.', + 'unreachable' => 'Paylaşıma ulaşılamadı.', + 'import_failed' => 'Bu dosyalar içe aktarılamadı.', + 'import_partial' => '{1} :names içe aktarılamadı.|[2,*] :count dosya içe aktarılamadı: :names', + ], ]; diff --git a/lang/uk/assets.php b/lang/uk/assets.php index a2dcea8c9..470251807 100644 --- a/lang/uk/assets.php +++ b/lang/uk/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => 'Спробуйте інший пошуковий запит.', 'powered_by' => 'Працює на GIPHY', ], + + 'webdav' => [ + 'up' => 'На теку вище', + 'import' => 'Імпортувати вибране (:count)', + 'loading' => 'Завантаження теки...', + 'empty' => 'Ця тека порожня.', + 'unreachable' => 'Не вдалося підключитися до спільного ресурсу.', + 'import_failed' => 'Не вдалося імпортувати ці файли.', + 'import_partial' => '{1} Не вдалося імпортувати :names.|[2,*] Не вдалося імпортувати файлів: :count — :names', + ], ]; diff --git a/lang/zh/assets.php b/lang/zh/assets.php index 142dccec4..1a1213a8c 100644 --- a/lang/zh/assets.php +++ b/lang/zh/assets.php @@ -51,4 +51,14 @@ 'no_results_description' => '换一个搜索词试试。', 'powered_by' => '由 GIPHY 提供支持', ], + + 'webdav' => [ + 'up' => '上一级文件夹', + 'import' => '导入所选 :count 项', + 'loading' => '正在加载文件夹…', + 'empty' => '此文件夹为空。', + 'unreachable' => '无法访问该共享。', + 'import_failed' => '这些文件无法导入。', + 'import_partial' => '{1} :names 无法导入。|[2,*] :count 个文件无法导入::names', + ], ]; diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index 1e79e4706..056045e94 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -1,7 +1,7 @@