updated attachments

This commit is contained in:
2023-07-19 14:56:56 +10:00
parent bfacb86f35
commit 1598efac35
11 changed files with 212 additions and 196 deletions

View File

@@ -8,17 +8,14 @@ use App\Enum\HttpResponseCodes;
use App\Http\Requests\ArticleRequest; use App\Http\Requests\ArticleRequest;
use App\Models\Media; use App\Models\Media;
use App\Models\Article; use App\Models\Article;
use App\Models\Gallery; use App\Traits\HasAttachments;
use App\Traits\HasGallery; use App\Traits\HasGallery;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Carbon\Exceptions\InvalidFormatException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\InvalidCastException;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ArticleController extends ApiController class ArticleController extends ApiController
{ {
use HasAttachments;
use HasGallery; use HasGallery;
@@ -78,7 +75,11 @@ class ArticleController extends ApiController
public function store(ArticleRequest $request) public function store(ArticleRequest $request)
{ {
if (ArticleConductor::creatable() === true) { if (ArticleConductor::creatable() === true) {
$article = Article::create($request->except('gallery')); $article = Article::create($request->except(['attachments', 'gallery']));
if ($request->has('attachments') === true) {
$article->attachmentsAddMany($request->get('attachments'));
}
if ($request->has('gallery') === true) { if ($request->has('gallery') === true) {
$article->galleryAddMany($request->get('gallery')); $article->galleryAddMany($request->get('gallery'));
@@ -103,12 +104,17 @@ class ArticleController extends ApiController
public function update(ArticleRequest $request, Article $article) public function update(ArticleRequest $request, Article $article)
{ {
if (ArticleConductor::updatable($article) === true) { if (ArticleConductor::updatable($article) === true) {
if ($request->has('attachments') === true) {
$article->attachments()->delete();
$article->attachmentsAddMany($request->get('attachments'));
}
if ($request->has('gallery') === true) { if ($request->has('gallery') === true) {
$article->gallery()->delete(); $article->gallery()->delete();
$article->galleryAddMany($request->get('gallery')); $article->galleryAddMany($request->get('gallery'));
} }
$article->update($request->except('gallery')); $article->update($request->except(['attachments', 'gallery']));
return $this->respondAsResource(ArticleConductor::model($request, $article)); return $this->respondAsResource(ArticleConductor::model($request, $article));
} }
@@ -130,125 +136,4 @@ class ArticleController extends ApiController
return $this->respondForbidden(); return $this->respondForbidden();
} }
} }
/**
* Get a list of attachments related to this model.
*
* @param Request $request The user request.
* @param Article $article The article model.
* @return JsonResponse Returns the article attachments.
*/
public function attachmentIndex(Request $request, Article $article): JsonResponse
{
if (ArticleConductor::viewable($article) === true) {
$medium = $article->attachments->map(function ($attachment) {
return $attachment->media;
});
return $this->respondAsResource(MediaConductor::collection($request, $medium), ['isCollection' => true, 'resourceName' => 'attachment']);
}
return $this->respondForbidden();
}
/**
* Store an attachment related to this model.
*
* @param Request $request The user request.
* @param Article $article The article model.
* @return JsonResponse The response.
*/
public function attachmentStore(Request $request, Article $article): JsonResponse
{
if (ArticleConductor::updatable($article) === true) {
if ($request->has("medium") === true && Media::find($request->medium) !== null) {
$article->attachments()->create(['media_id' => $request->medium]);
return $this->respondCreated();
}
return $this->respondWithErrors(['media' => 'The media ID was not found']);
}
return $this->respondForbidden();
}
/**
* Update/replace attachments related to this model.
*
* @param Request $request The user request.
* @param Article $article The related model.
* @return JsonResponse
*/
public function attachmentUpdate(Request $request, Article $article): JsonResponse
{
if (ArticleConductor::updatable($article) === true) {
$mediaIds = $request->attachments;
if (is_array($mediaIds) === false) {
$mediaIds = explode(',', $request->attachments);
}
$mediaIds = array_map('trim', $mediaIds); // trim each media ID
$attachments = $article->attachments;
// Delete attachments that are not in $mediaIds
foreach ($attachments as $attachment) {
if (in_array($attachment->media_id, $mediaIds) === false) {
$attachment->delete();
}
}
// Create new attachments for media IDs that are not already in $article->attachments()
foreach ($mediaIds as $mediaId) {
$found = false;
foreach ($attachments as $attachment) {
if ($attachment->media_id === $mediaId) {
$found = true;
break;
}
}
if ($found === false) {
$article->attachments()->create(['media_id' => $mediaId]);
}
}
return $this->respondNoContent();
}//end if
return $this->respondForbidden();
}
/**
* Delete a specific related attachment.
* @param Request $request The user request.
* @param Article $article The model.
* @param Media $medium The attachment medium.
* @return JsonResponse
*/
public function attachmentDelete(Request $request, Article $article, Media $medium): JsonResponse
{
if (ArticleConductor::updatable($article) === true) {
$attachments = $article->attachments;
$deleted = false;
foreach ($attachments as $attachment) {
if ($attachment->media_id === $medium->id) {
$attachment->delete();
$deleted = true;
break;
}
}
if ($deleted === true) {
// Attachment was deleted successfully
return $this->respondNoContent();
} else {
// Attachment with matching media ID was not found
return $this->respondNotFound();
}
}
return $this->respondForbidden();
}
} }

View File

@@ -39,14 +39,4 @@ class Article extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/**
* Get all of the article's attachments.
*
* @return Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function attachments(): MorphMany
{
return $this->morphMany(\App\Models\Attachment::class, 'attachable');
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasAttachments;
use App\Traits\Uuids; use App\Traits\Uuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
class Event extends Model class Event extends Model
{ {
use HasAttachments;
use HasFactory; use HasFactory;
use Uuids; use Uuids;
@@ -37,16 +39,10 @@ class Event extends Model
]; ];
/**
* Get all of the article's attachments.
*/
public function attachments(): MorphMany
{
return $this->morphMany(\App\Models\Attachment::class, 'attachable');
}
/** /**
* Get all the associated users. * Get all the associated users.
*
* @return BelongsToMany
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Traits;
use App\Models\Media;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasAttachments
{
/**
* Boot function from Laravel.
*
* @return void
*/
protected static function bootHasAttachments(): void
{
static::deleting(function ($model) {
$model->attachments()->delete();
});
}
/**
* Add multiple attachments items to the model.
*
* @param array|string $ids The media ids to add.
* @param string $delimiter The split delimiter if $ids is a string.
* @param boolean $allowDuplicates Whether to allow duplicate media IDs or not.
* @return void
*/
public function attachmentsAddMany(array|string $ids, string $delimiter = ',', bool $allowDuplicates = false): void
{
if (is_array($ids) === false) {
$ids = explode($delimiter, $ids);
}
$ids = array_map('trim', $ids);
$existingIds = $this->gallery()->pluck('media_id')->toArray();
$galleryItems = [];
foreach ($ids as $id) {
if ($allowDuplicates === false && in_array($id, $existingIds) === true) {
continue;
}
$media = Media::find($id);
if ($media !== null) {
$galleryItems[] = ['media_id' => $id];
}
}
$this->attachments()->createMany($galleryItems);
}
/**
* Get the article's attachments.
*
* @return Illuminate\Database\Eloquent\Relations\MorphMany The attachments items
*/
public function attachments(): MorphMany
{
return $this->morphMany(\App\Models\Attachment::class, 'addendum');
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->renameColumn('attachable_type', 'addendum_type');
$table->renameColumn('attachable_id', 'addendum_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->renameColumn('addendum_type', 'attachable_type');
$table->renameColumn('addendum_id', 'attachable_id');
});
}
};

View File

@@ -1,28 +1,32 @@
<template> <template>
<div> <div>
<SMHeader <SMHeader
v-if="props.attachments && props.attachments.length > 0" v-if="showEditor || (modelValue && modelValue.length > 0)"
:no-copy="props.showEditor"
text="Files" /> text="Files" />
<p v-if="props.showEditor" class="small">
{{ modelValue.length }} file{{ modelValue.length != 1 ? "s" : "" }}
</p>
<table <table
v-if="props.attachments && props.attachments.length > 0" v-if="modelValue && modelValue.length > 0"
class="w-full border-1 rounded-2 bg-white text-sm mt-2"> class="w-full border-1 rounded-2 bg-white text-sm mt-2">
<tbody> <tbody>
<tr v-for="file of props.attachments" :key="file.id"> <tr v-for="file of modelValue" :key="file.id">
<td class="py-2 pl-2"> <td class="py-2 pl-2 hidden sm:block">
<img <img
:src="getFileIconImagePath(file.name || file.title)" :src="getFileIconImagePath(file.name || file.title)"
class="h-10 text-center" /> class="h-10 text-center" />
</td> </td>
<td class=""> <td class="pl-2 py-4 w-full">
<a :href="file.url">{{ file.title || file.name }}</a> <a :href="file.url">{{ file.title || file.name }}</a>
</td> </td>
<td class=""> <td class="pr-2">
<a :href="file.url + '?download=1'" <a :href="file.url + '?download=1'"
><svg ><svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 text-gray"> class="h-7 pt-1 text-gray">
<path <path
d="M12 10V20M12 20L9.5 17.5M12 20L14.5 17.5" d="M12 10V20M12 20L9.5 17.5M12 20L14.5 17.5"
stroke="currentColor" stroke="currentColor"
@@ -37,12 +41,20 @@
</svg> </svg>
</a> </a>
</td> </td>
<td class="text-xs text-gray"> <td
class="text-xs text-gray whitespace-nowrap pr-2 py-2 hidden sm:table-cell">
({{ bytesReadable(file.size) }}) ({{ bytesReadable(file.size) }})
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<button
v-if="props.showEditor"
type="button"
class="font-medium mt-4 px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleClickAdd">
Add File
</button>
</div> </div>
</template> </template>
@@ -50,16 +62,53 @@
import { bytesReadable } from "../helpers/types"; import { bytesReadable } from "../helpers/types";
import { getFileIconImagePath } from "../helpers/utils"; import { getFileIconImagePath } from "../helpers/utils";
import SMHeader from "../components/SMHeader.vue"; import SMHeader from "../components/SMHeader.vue";
import { openDialog } from "../components/SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({ const props = defineProps({
attachments: { modelValue: {
type: Object, type: Array,
default: () => [],
required: true, required: true,
}, },
showEditor: {
type: Boolean,
default: false,
required: false,
},
}); });
/**
* Handle the user adding a new media item.
*/
const handleClickAdd = async () => {
let result = await openDialog(SMDialogMedia, {
mime: "",
accepts: "",
allowUpload: true,
multiple: true,
});
if (result) {
const mediaResult = result as Media[];
let newValue = props.modelValue;
let mediaIds = new Set(newValue.map((item) => item.id));
mediaResult.forEach((item) => {
if (!mediaIds.has(item.id)) {
newValue.push(item);
mediaIds.add(item.id);
}
});
emits("update:modelValue", newValue);
}
};
</script> </script>
<style lang="scss"> <!-- <style lang="scss">
.attachment-list { .attachment-list {
border: 1px solid var(--base-color); border: 1px solid var(--base-color);
border-collapse: collapse; border-collapse: collapse;
@@ -154,4 +203,4 @@ const props = defineProps({
} }
} }
} }
</style> </style> -->

View File

@@ -2,10 +2,12 @@
<component <component
:is="`h${props.size}`" :is="`h${props.size}`"
:id="id" :id="id"
class="sm-header cursor-pointer" :class="['sm-header', props.noCopy ? '' : 'cursor-pointer']"
@click.prevent="copyAnchor"> @click.prevent="copyAnchor">
{{ props.text }} {{ props.text }}
<span class="pl-2 text-sky-5 opacity-75 hidden">#</span> <span v-if="!props.noCopy" class="pl-2 text-sky-5 opacity-75 hidden"
>#</span
>
</component> </component>
</template> </template>
@@ -28,6 +30,11 @@ const props = defineProps({
default: "", default: "",
required: false, required: false,
}, },
noCopy: {
type: Boolean,
default: false,
required: false,
},
}); });
const computedHeaderId = (text: string): string => { const computedHeaderId = (text: string): string => {
@@ -35,29 +42,32 @@ const computedHeaderId = (text: string): string => {
}; };
const id = ref( const id = ref(
props.id && props.id.length > 0 ? props.id : computedHeaderId(props.text) props.id && props.id.length > 0 ? props.id : computedHeaderId(props.text),
); );
const copyAnchor = () => { const copyAnchor = () => {
const currentUrl = window.location.href.replace(/#.*/, ""); if (props.noCopy === false) {
const newUrl = currentUrl + "#" + id.value; const currentUrl = window.location.href.replace(/#.*/, "");
const newUrl = currentUrl + "#" + id.value;
navigator.clipboard navigator.clipboard
.writeText(newUrl) .writeText(newUrl)
.then(() => { .then(() => {
useToastStore().addToast({ useToastStore().addToast({
title: "Copied to Clipboard", title: "Copied to Clipboard",
content: "The heading URL has been copied to the clipboard.", content:
type: "success", "The heading URL has been copied to the clipboard.",
type: "success",
});
})
.catch(() => {
useToastStore().addToast({
title: "Copy to Clipboard",
content: "Failed to copy the heading URL to the clipboard.",
type: "danger",
});
}); });
}) }
.catch(() => {
useToastStore().addToast({
title: "Copy to Clipboard",
content: "Failed to copy the heading URL to the clipboard.",
type: "danger",
});
});
}; };
</script> </script>

View File

@@ -1,6 +1,5 @@
<template> <template>
<div class="input-attachments"> <div class="input-attachments">
<label>Files</label>
<ul> <ul>
<li v-if="mediaItems.length == 0" class="attachments-none"> <li v-if="mediaItems.length == 0" class="attachments-none">
<ion-icon name="sad-outline"></ion-icon> <ion-icon name="sad-outline"></ion-icon>

View File

@@ -41,7 +41,7 @@
:model-value="article.gallery" /> :model-value="article.gallery" />
<SMAttachments <SMAttachments
v-if="article.attachments.length > 0" v-if="article.attachments.length > 0"
:attachments="article.attachments || []" /> :model-value="article.attachments || []" />
</div> </div>
</template> </template>
</template> </template>
@@ -136,6 +136,8 @@ const handleLoad = async () => {
"large", "large",
); );
applicationStore.setDynamicTitle(article.value.title); applicationStore.setDynamicTitle(article.value.title);
console.log(article.value);
} else { } else {
pageStatus.value = 404; pageStatus.value = 404;
} }

View File

@@ -56,7 +56,10 @@
</p> </p>
<SMImageGallery show-editor v-model:model-value="gallery" /> <SMImageGallery show-editor v-model:model-value="gallery" />
</div> </div>
<SMInputAttachments v-model:model-value="attachments" /> <SMAttachments
class="mb-8"
show-editor
v-model:model-value="attachments" />
<div class="flex flex-justify-end"> <div class="flex flex-justify-end">
<input <input
type="submit" type="submit"
@@ -75,7 +78,7 @@ import SMEditor from "../../components/SMEditor.vue";
import SMForm from "../../components/SMForm.vue"; import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue"; import SMInput from "../../components/SMInput.vue";
import SMDropdown from "../../components/SMDropdown.vue"; import SMDropdown from "../../components/SMDropdown.vue";
import SMInputAttachments from "../../components/SMInputAttachments.vue"; import SMAttachments from "../../components/SMAttachments.vue";
import { api } from "../../helpers/api"; import { api } from "../../helpers/api";
import { ArticleResponse, UserCollection } from "../../helpers/api.types"; import { ArticleResponse, UserCollection } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime"; import { SMDate } from "../../helpers/datetime";
@@ -181,12 +184,7 @@ const loadData = async () => {
form.controls.content.value = data.article.content; form.controls.content.value = data.article.content;
form.controls.hero.value = data.article.hero; form.controls.hero.value = data.article.hero;
attachments.value = (data.article.attachments || []).map( attachments.value = data.article.attachments;
function (attachment) {
return attachment.id.toString();
},
);
gallery.value = data.article.gallery; gallery.value = data.article.gallery;
} else { } else {
pageError.value = 404; pageError.value = 404;
@@ -211,6 +209,7 @@ const handleSubmit = async () => {
content: form.controls.content.value, content: form.controls.content.value,
hero: form.controls.hero.value.id, hero: form.controls.hero.value.id,
gallery: gallery.value.map((item) => item.id), gallery: gallery.value.map((item) => item.id),
attachments: attachments.value.map((item) => item.id),
}; };
let article_id = ""; let article_id = "";
@@ -236,13 +235,6 @@ const handleSubmit = async () => {
} }
} }
await api.put({
url: `/articles/${article_id}/attachments`,
body: {
attachments: attachments.value,
},
});
useToastStore().addToast({ useToastStore().addToast({
title: route.params.id ? "Article Updated" : "Article Created", title: route.params.id ? "Article Updated" : "Article Created",
content: route.params.id content: route.params.id

View File

@@ -44,7 +44,7 @@ Route::apiResource('media', MediaController::class);
Route::get('media/{medium}/download', [MediaController::class, 'download']); Route::get('media/{medium}/download', [MediaController::class, 'download']);
Route::apiResource('articles', ArticleController::class); Route::apiResource('articles', ArticleController::class);
Route::apiAddendumResource('attachments', 'articles', ArticleController::class); // Route::apiAddendumResource('attachments', 'articles', ArticleController::class);
Route::apiResource('events', EventController::class); Route::apiResource('events', EventController::class);
Route::apiAddendumResource('attachments', 'events', EventController::class); Route::apiAddendumResource('attachments', 'events', EventController::class);