mirror of
https://github.com/brufdev/many-notes.git
synced 2026-01-25 20:39:21 -06:00
Move components logic to actions
This commit is contained in:
78
app/Actions/ExportVault.php
Normal file
78
app/Actions/ExportVault.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Vault;
|
||||
use App\Models\VaultNode;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection;
|
||||
use ZipArchive;
|
||||
|
||||
final readonly class ExportVault
|
||||
{
|
||||
public function handle(Vault $vault): string
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$relativePath = 'public/' . Str::random(16) . '.zip';
|
||||
$path = Storage::disk('local')->path($relativePath);
|
||||
$nodes = $vault->nodes()->whereNull('parent_id')->get();
|
||||
|
||||
if ($nodes->count() === 0) {
|
||||
throw new Exception(__('Your vault is empty'));
|
||||
}
|
||||
|
||||
Storage::disk('local')->put($relativePath, '');
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new Exception(__('Something went wrong'));
|
||||
}
|
||||
|
||||
$this->exportNodes($zip, $nodes);
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, VaultNode> $nodes
|
||||
*/
|
||||
private function exportNodes(ZipArchive &$zip, Collection $nodes, string $path = ''): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$nodePath = mb_ltrim("$path/$node->name", '/');
|
||||
$nodePath .= $node->is_file ? ".$node->extension" : '';
|
||||
$relativePath = new GetPathFromVaultNode()->handle($node);
|
||||
|
||||
if (!Storage::disk('local')->exists($relativePath)) {
|
||||
throw new Exception(
|
||||
sprintf(
|
||||
"%s missing on disk: {$nodePath}",
|
||||
$node->is_file ? 'File' : 'Folder',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($node->is_file) {
|
||||
if ($node->extension === 'md') {
|
||||
$zip->addFromString($nodePath, (string) $node->content);
|
||||
} else {
|
||||
$relativePath = new GetPathFromVaultNode()->handle($node);
|
||||
|
||||
$zip->addFile(
|
||||
Storage::disk('local')->path($relativePath),
|
||||
$nodePath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$zip->addEmptyDir($nodePath);
|
||||
|
||||
if ($node->children()->count()) {
|
||||
$this->exportNodes($zip, $node->children()->get(), $nodePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Actions;
|
||||
|
||||
use App\Models\Vault;
|
||||
use App\Models\VaultNode;
|
||||
use App\Services\VaultFile;
|
||||
use App\Services\VaultFiles\Note;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -15,53 +14,19 @@ final readonly class ProcessImportedFile
|
||||
{
|
||||
public function handle(Vault $vault, VaultNode $parent, string $fileName, string $filePath): void
|
||||
{
|
||||
$pathInfo = pathinfo($fileName);
|
||||
$name = $pathInfo['filename'];
|
||||
$extension = $pathInfo['extension'] ?? '';
|
||||
|
||||
if (!in_array($extension, VaultFile::extensions())) {
|
||||
abort(400);
|
||||
}
|
||||
|
||||
$content = null;
|
||||
if (in_array($extension, Note::extensions())) {
|
||||
$extension = 'md';
|
||||
$content = file_get_contents($filePath);
|
||||
}
|
||||
|
||||
// Find new filename if it already exists
|
||||
$nodeExists = $vault->nodes()
|
||||
->where('parent_id', $parent->id)
|
||||
->where('is_file', true)
|
||||
->where('name', 'like', "$name")
|
||||
->where('extension', 'md')
|
||||
->exists();
|
||||
if ($nodeExists) {
|
||||
/** @var list<string> $nodes */
|
||||
$nodes = array_column(
|
||||
$vault->nodes()
|
||||
->select('name')
|
||||
->where('parent_id', $parent->id)
|
||||
->where('is_file', true)
|
||||
->where('name', 'like', "$name-%")
|
||||
->where('extension', 'md')
|
||||
->get()
|
||||
->toArray(),
|
||||
'name',
|
||||
);
|
||||
natcasesort($nodes);
|
||||
$name .= count($nodes) && preg_match('/-(\d+)$/', end($nodes), $matches) === 1 ?
|
||||
'-' . ((int) $matches[1] + 1) :
|
||||
'-1';
|
||||
}
|
||||
|
||||
$node = $vault->nodes()->createQuietly([
|
||||
$attributes = [
|
||||
'parent_id' => $parent->id,
|
||||
'is_file' => true,
|
||||
'name' => $name,
|
||||
'extension' => $extension,
|
||||
'content' => $content,
|
||||
]);
|
||||
];
|
||||
$pathInfo = pathinfo($fileName);
|
||||
$attributes['name'] = $pathInfo['filename'];
|
||||
$attributes['extension'] = $pathInfo['extension'] ?? '';
|
||||
$attributes['content'] = null;
|
||||
if (in_array($attributes['extension'], Note::extensions())) {
|
||||
$attributes['extension'] = 'md';
|
||||
$attributes['content'] = (string) file_get_contents($filePath);
|
||||
}
|
||||
$node = new CreateVaultNode()->handle($vault, $attributes);
|
||||
|
||||
$relativePath = new GetPathFromVaultNode()->handle($node);
|
||||
$pathInfo = pathinfo($relativePath);
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Actions;
|
||||
use App\Models\User;
|
||||
use App\Services\VaultFile;
|
||||
use App\Services\VaultFiles\Note;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ZipArchive;
|
||||
|
||||
final readonly class ProcessImportedVault
|
||||
@@ -15,32 +14,10 @@ final readonly class ProcessImportedVault
|
||||
public function handle(string $fileName, string $filePath): void
|
||||
{
|
||||
$nodeIds = ['.' => null];
|
||||
$vaultName = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
/** @var User $currentUser */
|
||||
$currentUser = auth()->user();
|
||||
// Create vault with zip name
|
||||
$vaultName = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
// Find new vault name if it already exists
|
||||
$vaultExists = $currentUser->vaults()
|
||||
->where('name', 'like', "$vaultName")
|
||||
->exists();
|
||||
|
||||
if ($vaultExists) {
|
||||
/** @var list<string> $vaults */
|
||||
$vaults = array_column(
|
||||
$currentUser->vaults()
|
||||
->select('name')
|
||||
->where('name', 'like', "$vaultName-%")
|
||||
->get()
|
||||
->toArray(),
|
||||
'name',
|
||||
);
|
||||
natcasesort($vaults);
|
||||
$vaultName .= count($vaults) && preg_match('/-(\d+)$/', end($vaults), $matches) === 1 ?
|
||||
'-' . ((int) $matches[1] + 1) :
|
||||
'-1';
|
||||
}
|
||||
|
||||
$vault = $currentUser->vaults()->create([
|
||||
$vault = new CreateVault()->handle($currentUser, [
|
||||
'name' => $vaultName,
|
||||
]);
|
||||
|
||||
@@ -56,48 +33,36 @@ final readonly class ProcessImportedVault
|
||||
|
||||
$isFile = !str_ends_with($entryName, '/');
|
||||
$flags = $isFile ? PATHINFO_FILENAME : PATHINFO_BASENAME;
|
||||
$name = pathinfo($entryName, $flags);
|
||||
$extension = null;
|
||||
$content = null;
|
||||
$attributes = [
|
||||
'is_file' => $isFile,
|
||||
'name' => pathinfo($entryName, $flags),
|
||||
'extension' => null,
|
||||
'content' => null,
|
||||
];
|
||||
|
||||
if (!$isFile) {
|
||||
// ZipArchive folder paths end with a / that should
|
||||
// be removed in order for pathinfo() return the correct dirname
|
||||
$entryDirName = mb_rtrim($entryName, '/');
|
||||
$entryParentDirName = pathinfo($entryDirName, PATHINFO_DIRNAME);
|
||||
$parentId = $nodeIds[$entryParentDirName];
|
||||
$attributes['parent_id'] = $nodeIds[$entryParentDirName];
|
||||
} else {
|
||||
$pathInfo = pathinfo($entryName);
|
||||
$entryDirName = $pathInfo['dirname'];
|
||||
$extension = $pathInfo['extension'] ?? '';
|
||||
$parentId = $nodeIds[$entryDirName];
|
||||
$attributes['extension'] = $pathInfo['extension'] ?? '';
|
||||
$attributes['parent_id'] = $nodeIds[$entryDirName];
|
||||
|
||||
if (!in_array($extension, VaultFile::extensions())) {
|
||||
if (!in_array($attributes['extension'], VaultFile::extensions())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($extension, Note::extensions())) {
|
||||
$extension = 'md';
|
||||
$content = $zip->getFromIndex($i);
|
||||
if (in_array($attributes['extension'], Note::extensions())) {
|
||||
$attributes['extension'] = 'md';
|
||||
}
|
||||
}
|
||||
|
||||
$node = $vault->nodes()->createQuietly([
|
||||
'parent_id' => $parentId,
|
||||
'is_file' => $isFile,
|
||||
'name' => $name,
|
||||
'extension' => $extension,
|
||||
'content' => $content,
|
||||
]);
|
||||
|
||||
$relativePath = new GetPathFromVaultNode()->handle($node);
|
||||
if ($isFile) {
|
||||
/** @var string $contents */
|
||||
$contents = $zip->getFromIndex($i);
|
||||
Storage::disk('local')->put($relativePath, $contents);
|
||||
} else {
|
||||
Storage::disk('local')->makeDirectory($relativePath);
|
||||
$attributes['content'] = (string) $zip->getFromIndex($i);
|
||||
}
|
||||
$node = new CreateVaultNode()->handle($vault, $attributes);
|
||||
|
||||
if (!array_key_exists($entryDirName, $nodeIds)) {
|
||||
$nodeIds[$entryDirName] = $node->id;
|
||||
|
||||
@@ -4,21 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Vault;
|
||||
|
||||
use App\Actions\GetPathFromVaultNode;
|
||||
use App\Actions\DeleteVault;
|
||||
use App\Actions\ExportVault;
|
||||
use App\Livewire\Forms\VaultForm;
|
||||
use App\Models\User;
|
||||
use App\Models\Vault;
|
||||
use App\Models\VaultNode;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
final class Index extends Component
|
||||
{
|
||||
@@ -33,41 +28,34 @@ final class Index extends Component
|
||||
$this->dispatch('toast', message: __('Vault created'), type: 'success');
|
||||
}
|
||||
|
||||
public function export(Vault $vault): ?BinaryFileResponse
|
||||
public function export(Vault $vault, ExportVault $exportVault): ?BinaryFileResponse
|
||||
{
|
||||
$this->authorize('view', $vault);
|
||||
$zip = new ZipArchive();
|
||||
$zipFileName = $vault->id . '.zip';
|
||||
$nodes = $vault->nodes()->whereNull('parent_id')->get();
|
||||
|
||||
if ($zip->open(public_path($zipFileName), ZipArchive::CREATE) !== true) {
|
||||
$this->dispatch('toast', message: __('Something went wrong'), type: 'error');
|
||||
try {
|
||||
$path = $exportVault->handle($vault);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('toast', message: $e->getMessage(), type: 'error');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->exportNodes($zip, $nodes);
|
||||
$zip->close();
|
||||
|
||||
return response()->download(public_path($zipFileName), $vault->name . '.zip')->deleteFileAfterSend(true);
|
||||
return response()->download($path, $vault->name . '.zip')->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
public function delete(Vault $vault): void
|
||||
{
|
||||
$this->authorize('delete', $vault);
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$rootNodes = $vault->nodes()->whereNull('parent_id')->get();
|
||||
foreach ($rootNodes as $node) {
|
||||
$this->deleteNode($node);
|
||||
}
|
||||
$vault->delete();
|
||||
DB::commit();
|
||||
$this->dispatch('toast', message: __('Vault deleted'), type: 'success');
|
||||
} catch (Throwable) {
|
||||
DB::rollBack();
|
||||
$this->dispatch('toast', message: __('Something went wrong'), type: 'error');
|
||||
new DeleteVault()->handle($vault);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('toast', message: $e->getMessage(), type: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('toast', message: __('Vault deleted'), type: 'success');
|
||||
}
|
||||
|
||||
public function render(): Factory|View
|
||||
@@ -79,38 +67,4 @@ final class Index extends Component
|
||||
'vaults' => $currentUser->vaults()->orderBy('updated_at', 'DESC')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, VaultNode> $nodes
|
||||
*/
|
||||
private function exportNodes(ZipArchive &$zip, Collection $nodes, string $path = ''): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$nodePath = Str::ltrim("$path/$node->name", '/');
|
||||
|
||||
if ($node->is_file) {
|
||||
if ($node->extension === 'md') {
|
||||
$zip->addFromString("$nodePath.$node->extension", (string) $node->content);
|
||||
} else {
|
||||
$relativePath = new GetPathFromVaultNode()->handle($node);
|
||||
$filePath = Storage::disk('local')->path($relativePath);
|
||||
$zip->addFile($filePath, "$nodePath.$node->extension");
|
||||
}
|
||||
} else {
|
||||
$zip->addEmptyDir($nodePath);
|
||||
|
||||
if ($node->children->count()) {
|
||||
$this->exportNodes($zip, $node->children, $nodePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteNode(VaultNode $node): void
|
||||
{
|
||||
foreach ($node->childs as $child) {
|
||||
$this->deleteNode($child);
|
||||
}
|
||||
$node->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Vault;
|
||||
|
||||
use App\Actions\GetPathFromVaultNode;
|
||||
use App\Actions\DeleteVaultNode;
|
||||
use App\Actions\GetUrlFromVaultNode;
|
||||
use App\Actions\GetVaultNodeFromPath;
|
||||
use App\Actions\ResolveTwoPaths;
|
||||
use App\Actions\UpdateVault;
|
||||
use App\Livewire\Forms\VaultForm;
|
||||
use App\Livewire\Forms\VaultNodeForm;
|
||||
use App\Models\Vault;
|
||||
@@ -36,13 +37,10 @@ final class Show extends Component
|
||||
#[Url(as: 'file')]
|
||||
public ?int $selectedFile = null;
|
||||
|
||||
public ?string $selectedFilePath = null;
|
||||
public ?string $selectedFileUrl = null;
|
||||
|
||||
public bool $isEditMode = true;
|
||||
|
||||
/** @var list<VaultNode> */
|
||||
private array $deletedNodes = [];
|
||||
|
||||
public function mount(Vault $vault): void
|
||||
{
|
||||
$this->authorize('view', $vault);
|
||||
@@ -52,7 +50,10 @@ final class Show extends Component
|
||||
$this->getTemplates();
|
||||
|
||||
if ((int) $this->selectedFile > 0) {
|
||||
$selectedFile = $vault->nodes()->where('id', $this->selectedFile)->first();
|
||||
$selectedFile = $vault->nodes()
|
||||
->where('id', $this->selectedFile)
|
||||
->where('is_file', true)
|
||||
->first();
|
||||
|
||||
if (!$selectedFile) {
|
||||
$this->selectedFile = null;
|
||||
@@ -69,11 +70,13 @@ final class Show extends Component
|
||||
$this->authorize('view', $node->vault);
|
||||
|
||||
if (!$node->vault || !$node->vault->is($this->vault) || !$node->is_file) {
|
||||
$this->selectedFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedFile = $node->id;
|
||||
$this->selectedFilePath = new GetUrlFromVaultNode()->handle($node);
|
||||
$this->selectedFileUrl = new GetUrlFromVaultNode()->handle($node);
|
||||
$this->nodeForm->setNode($node);
|
||||
|
||||
if ($node->extension === 'md') {
|
||||
@@ -85,12 +88,11 @@ final class Show extends Component
|
||||
|
||||
public function openFilePath(string $path): void
|
||||
{
|
||||
/**
|
||||
* @var string $currentPath
|
||||
*
|
||||
* @phpstan-ignore-next-line larastan.noUnnecessaryCollectionCall
|
||||
*/
|
||||
$currentPath = $this->nodeForm->node->ancestorsAndSelf()->get()->last()->full_path;
|
||||
/** @var string $currentPath */
|
||||
$currentPath = is_null($this->nodeForm->node)
|
||||
? ''
|
||||
/** @phpstan-ignore-next-line larastan.noUnnecessaryCollectionCall */
|
||||
: $this->nodeForm->node->ancestorsAndSelf()->get()->last()->full_path;
|
||||
$resolvedPath = new ResolveTwoPaths()->handle($currentPath, $path);
|
||||
$node = new GetVaultNodeFromPath()->handle($this->vault->id, $resolvedPath);
|
||||
|
||||
@@ -110,14 +112,13 @@ final class Show extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedFile = $node->id;
|
||||
$this->selectedFilePath = new GetPathFromVaultNode()->handle($node);
|
||||
$this->selectedFileUrl = new GetUrlFromVaultNode()->handle($node);
|
||||
$this->nodeForm->setNode($node);
|
||||
}
|
||||
|
||||
public function closeFile(): void
|
||||
{
|
||||
$this->reset(['selectedFile', 'selectedFilePath']);
|
||||
$this->reset(['selectedFile', 'selectedFileUrl']);
|
||||
$this->nodeForm->reset('node');
|
||||
}
|
||||
|
||||
@@ -157,7 +158,9 @@ final class Show extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->vault->update(['templates_node_id' => $node->id]);
|
||||
new UpdateVault()->handle($this->vault, [
|
||||
'templates_node_id' => $node->id,
|
||||
]);
|
||||
$this->getTemplates();
|
||||
$this->dispatch('toast', message: __('Template folder updated'), type: 'success');
|
||||
}
|
||||
@@ -212,30 +215,38 @@ final class Show extends Component
|
||||
{
|
||||
$this->authorize('delete', $node->vault);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
if ($node->is_file) {
|
||||
$this->deleteFile($node);
|
||||
} else {
|
||||
$this->deleteFolder($node);
|
||||
DB::beginTransaction();
|
||||
$deletedNodes = new DeleteVaultNode()->handle($node);
|
||||
$this->dispatch('node-updated');
|
||||
|
||||
$openFileDeleted = !is_null(
|
||||
array_find(
|
||||
$deletedNodes,
|
||||
fn (VaultNode $node): bool => $node->id === $this->selectedFile,
|
||||
)
|
||||
);
|
||||
if ($openFileDeleted) {
|
||||
$this->closeFile();
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->dispatch('node-updated');
|
||||
$templateDeleted = !is_null(
|
||||
array_find(
|
||||
$this->deletedNodes,
|
||||
$deletedNodes,
|
||||
fn (VaultNode $node): bool => $node->parent_id === $this->vault->templates_node_id,
|
||||
)
|
||||
);
|
||||
if ($templateDeleted) {
|
||||
$this->getTemplates();
|
||||
}
|
||||
$this->deletedNodes = [];
|
||||
|
||||
DB::commit();
|
||||
$message = $node->is_file ? __('File deleted') : __('Folder deleted');
|
||||
$this->dispatch('toast', message: $message, type: 'success');
|
||||
} catch (Throwable) {
|
||||
} catch (Throwable $e) {
|
||||
DB::rollBack();
|
||||
|
||||
$this->dispatch('toast', message: $e->getMessage(), type: 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,28 +254,4 @@ final class Show extends Component
|
||||
{
|
||||
return view('livewire.vault.show');
|
||||
}
|
||||
|
||||
private function deleteFile(VaultNode $node): void
|
||||
{
|
||||
if ($this->selectedFile === $node->id) {
|
||||
$this->closeFile();
|
||||
}
|
||||
|
||||
$this->deletedNodes[] = $node;
|
||||
$node->delete();
|
||||
}
|
||||
|
||||
private function deleteFolder(VaultNode $node): void
|
||||
{
|
||||
foreach ($node->childs as $child) {
|
||||
if ($child->is_file) {
|
||||
$this->deleteFile($child);
|
||||
} else {
|
||||
$this->deleteFolder($child);
|
||||
}
|
||||
}
|
||||
|
||||
$this->deletedNodes[] = $node;
|
||||
$node->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,20 +163,20 @@
|
||||
<x-markdownEditor />
|
||||
@elseif (in_array($nodeForm->extension, App\Services\VaultFiles\Image::extensions()))
|
||||
<div>
|
||||
<img src="{{ $selectedFilePath }}" />
|
||||
<img src="{{ $selectedFileUrl }}" />
|
||||
</div>
|
||||
@elseif (in_array($nodeForm->extension, App\Services\VaultFiles\Pdf::extensions()))
|
||||
<object type="application/pdf" data="{{ $selectedFilePath }}"
|
||||
<object type="application/pdf" data="{{ $selectedFileUrl }}"
|
||||
class="w-full h-full"></object>
|
||||
@elseif (in_array($nodeForm->extension, App\Services\VaultFiles\Video::extensions()))
|
||||
<video class="w-full" controls>
|
||||
<source src="{{ $selectedFilePath }}" />
|
||||
<source src="{{ $selectedFileUrl }}" />
|
||||
{{ __('Your browser does not support the video tag') }}
|
||||
</video>
|
||||
@elseif (in_array($nodeForm->extension, App\Services\VaultFiles\Audio::extensions()))
|
||||
<div class="flex items-start justify-center w-full">
|
||||
<audio class="w-full" controls>
|
||||
<source src="{{ $selectedFilePath }}">
|
||||
<source src="{{ $selectedFileUrl }}">
|
||||
{{ __('Your browser does not support the audio tag') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user