@ -0,0 +1,105 @@ |
||||
<?php |
||||
|
||||
namespace App\Http\Controllers; |
||||
|
||||
use App\Utils\S3; |
||||
use Carbon\Carbon; |
||||
use Illuminate\Http\Request; |
||||
use Illuminate\Support\Facades\Auth; |
||||
use Illuminate\Support\Str; |
||||
use Illuminate\Support\Facades\Http; |
||||
use Illuminate\Support\Facades\Storage; |
||||
use Normalizer; |
||||
|
||||
|
||||
class S3Controller extends Controller |
||||
{ |
||||
private $bucket; |
||||
|
||||
public function __construct() { |
||||
$this->bucket = config("filesystems.disks.s3.bucket"); |
||||
} |
||||
|
||||
private static function NormalizeName($name) { |
||||
// Normalize the filename according to the NFC convention |
||||
// Requires php-intl module |
||||
// More info: https://www.php.net/manual/en/class.normalizer.php |
||||
if (class_exists('Normalizer')) { |
||||
$normalized_filename = Normalizer::normalize($name, Normalizer::FORM_C); |
||||
if ($normalized_filename !== false) { |
||||
return $normalized_filename; |
||||
} |
||||
} else { |
||||
// If Normalizer is not installed, we transform the text into an |
||||
// ASCII representation without diacritics. |
||||
return iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name); |
||||
} |
||||
|
||||
return $name; |
||||
} |
||||
|
||||
|
||||
public function GeneratePresignedUrl(Request $request) |
||||
{ |
||||
return response()->json([ "url" => S3::signUrl($request->key) ]); |
||||
} |
||||
|
||||
public function StartMultipartUpload(Request $request) |
||||
{ |
||||
$filename = "tmp/" . Str::random(10) . "_" . Str::replace(" ", "_", $request->filename); |
||||
$client = ((object)Storage::disk("s3"))->getClient(); |
||||
$result = $client->createMultipartUpload([ |
||||
"Bucket" => $this->bucket, |
||||
"Key" => $filename, |
||||
'ContentDisposition' => 'inline', |
||||
]); |
||||
return response()->json([ |
||||
"uploadId" => $result["UploadId"], |
||||
"key" => $filename, |
||||
]); |
||||
} |
||||
|
||||
public function GeneratePresignedMultipartUrl(Request $request) |
||||
{ |
||||
$client = ((object)Storage::disk("s3"))->getClient(); |
||||
$command = $client->getCommand('UploadPart', [ |
||||
"Bucket" => $this->bucket, |
||||
"Key" => $request->key, |
||||
"UploadId" => $request->uploadId, |
||||
"ContentLength" => $request->partLength, |
||||
"PartNumber" => $request->partNumber, |
||||
]); |
||||
$preSignedUrl = $client->createPresignedRequest($command, Carbon::tomorrow()); |
||||
return response()->json([ "url" => (string)$preSignedUrl->getUri() ]); |
||||
} |
||||
|
||||
public function CompleteMultipartUpload(Request $request) |
||||
{ |
||||
$client = ((object)Storage::disk("s3"))->getClient(); |
||||
$result = $client->completeMultipartUpload([ |
||||
"Bucket" => $this->bucket, |
||||
"Key" => $request->key, |
||||
"UploadId" => $request->uploadId, |
||||
"MultipartUpload" => [ |
||||
"Parts" => $request->parts |
||||
] |
||||
]); |
||||
return response()->json([ "message" => "Upload completed", "Location" => $result["Location"] ]); |
||||
} |
||||
|
||||
public function ProxyS3(Request $request) |
||||
{ |
||||
$response = Http::withBody($request->getContent(), "binary/octet-stream")->withHeaders([ "Content-Length" => $request->header("Content-Length") ])->put($request->header("X-SignedUrl")); |
||||
$ETag = $response->getHeader("ETag") ? $response->getHeader("ETag")[0] : dd($response); |
||||
return response(json_encode([ |
||||
"ETag" => $ETag |
||||
]), $response->status()); |
||||
} |
||||
|
||||
public function Download(Request $request) |
||||
{ |
||||
$url = S3::signUrl($request->key); |
||||
return redirect($url); |
||||
} |
||||
} |
||||
|
@ -0,0 +1,25 @@ |
||||
<?php |
||||
|
||||
namespace App\Utils; |
||||
|
||||
use Carbon\Carbon; |
||||
use Illuminate\Support\Facades\Storage; |
||||
|
||||
class S3 |
||||
{ |
||||
public static function encodeURI($str) |
||||
{ |
||||
$revert = ['%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')', '%2F'=>'/']; |
||||
return strtr(rawurlencode($str), $revert); |
||||
} |
||||
|
||||
public static function signUrl($key){ |
||||
$client = ((object)Storage::disk("s3"))->getClient(); |
||||
$bucket = config("filesystems.disks.s3.bucket"); |
||||
$command = $client->getCommand('GetObject', [ |
||||
"Bucket" => $bucket, |
||||
"Key" => $key |
||||
]); |
||||
return (string)$client->createPresignedRequest($command, Carbon::tomorrow())->getUri(); |
||||
} |
||||
} |
@ -0,0 +1,28 @@ |
||||
<?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('photos', function (Blueprint $table) { |
||||
$table->uuid('uuid')->unique()->after('id'); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Reverse the migrations. |
||||
*/ |
||||
public function down(): void |
||||
{ |
||||
Schema::table('photos', function (Blueprint $table) { |
||||
$table->dropColumn("uuid"); |
||||
}); |
||||
} |
||||
}; |
@ -0,0 +1,32 @@ |
||||
<?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("users", function (Blueprint $table) { |
||||
$table->string("totem", 100)->default(""); |
||||
$table->string("tel", 25)->default(""); |
||||
$table->string("role", 255)->default(""); |
||||
$table->tinyInteger("contactable")->default(0); |
||||
$table->string("path")->default("profiles/none.png"); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Reverse the migrations. |
||||
*/ |
||||
public function down(): void |
||||
{ |
||||
Schema::table("users", function (Blueprint $table) { |
||||
$table->dropColumns(["totem", "tel", "role"]); |
||||
}); |
||||
} |
||||
}; |
@ -0,0 +1,27 @@ |
||||
<?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("photos", function (Blueprint $table) { |
||||
$table->unsignedBigInteger("user_id"); |
||||
$table->foreign("user_id")->references("id")->on("users"); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Reverse the migrations. |
||||
*/ |
||||
public function down(): void |
||||
{ |
||||
// |
||||
} |
||||
}; |
After Width: | Height: | Size: 1011 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 524 B |
After Width: | Height: | Size: 974 B |
After Width: | Height: | Size: 825 B |
After Width: | Height: | Size: 751 B |
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 580 B |
After Width: | Height: | Size: 833 B |
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 645 B |
After Width: | Height: | Size: 710 B |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 349 B |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 616 B |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 462 B |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 611 B |
After Width: | Height: | Size: 858 B |
After Width: | Height: | Size: 774 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 811 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 409 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 528 B |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 937 B |
After Width: | Height: | Size: 7.5 KiB |
After Width: | Height: | Size: 335 B |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 347 B |
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 788 B |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 415 B |
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 558 B |
After Width: | Height: | Size: 344 B |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 739 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 756 B |
After Width: | Height: | Size: 687 B |
After Width: | Height: | Size: 645 B |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 950 B |
After Width: | Height: | Size: 746 B |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 888 B |
After Width: | Height: | Size: 875 B |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 456 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 513 B |
After Width: | Height: | Size: 719 B |
After Width: | Height: | Size: 464 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 456 B |
Before Width: | Height: | Size: 620 B After Width: | Height: | Size: 620 B |
After Width: | Height: | Size: 854 B |
After Width: | Height: | Size: 602 B |
After Width: | Height: | Size: 5.6 KiB |
After Width: | Height: | Size: 808 B |
After Width: | Height: | Size: 747 B |
After Width: | Height: | Size: 5.8 MiB |
After Width: | Height: | Size: 8.0 MiB |
@ -0,0 +1,142 @@ |
||||
<template> |
||||
<div @click="openDialog" :id="'dz-' + props.name" @drop="dropFiles" @dragover="Utils.Prevent" @drag="Utils.Prevent" @dragenter="popupShow" @dragleave="popupClose" |
||||
:class="{ 'border-dashed': !props.disabled }" |
||||
class="w-full relative bg-gray-200 dark:bg-gray-900 border-2 border-gray-400 overflow-hidden p-4 flex flex-col items-center rounded-lg text-lg text-gray-900 dark:text-gray-200"> |
||||
<div :id="'dz-popup-'+ props.name" class="hidden absolute z-20 bg-gray-400/90 top-0 bottom-0 left-0 right-0 |
||||
pointer-events-none"> |
||||
<div class="w-full h-full flex justify-center items-center"> |
||||
<img src="/icons/download.png" class="invert absolute h-[80%]"> |
||||
</div> |
||||
</div> |
||||
<input @change="fileSelected" :id="'dz-input-' + props.name" type="file" class="hidden" :multiple="props.multiple" :accept="props.accept"> |
||||
<div v-for="file in files" class="w-full flex items-center"> |
||||
<div class="relative w-full flex justify-between items-center bg-gray-100 dark:bg-gray-800 |
||||
rounded-lg mb-1 p-2 mr-2 z-0 "> |
||||
<a :href="file.url" target="_blank" class="w-full flex justify-between items-center"> |
||||
<div v-if="file.value.stat === 'loading'" class="absolute top-0 right-0 left-0 bottom-0"> |
||||
<div :style="{ width: file.value.done + '%' }" class="bg-green-600 h-full -z-10 rounded-lg"></div> |
||||
</div> |
||||
<div class="flex items-center z-10"> |
||||
<img :src="File.Icon(File.Extension(file.value.name))" class="h-10 dark:invert"> |
||||
<p class="font-semibold ml-2">{{ file.value.name }}</p> |
||||
</div> |
||||
<div class="text-right z-10"> |
||||
<p>{{ File.SizeToString(file.value.size) }}</p> |
||||
<p v-if="file.value.stat !== 'loading'" @click="removeFile" :data-name="file.value.name" class="underline text-red-600 text-sm cursor-pointer">Supprimer</p> |
||||
</div> |
||||
</a> |
||||
</div> |
||||
<div v-if="file.value.stat === 'loading'" class="flex items-center"> |
||||
<img src="/icons/loading.svg" class="invert h-10 load-rotate"> |
||||
</div> |
||||
<div v-else-if="file.value.stat === 'success'" class="flex items-center"> |
||||
<img src="/icons/success.svg" class="h-10"> |
||||
</div> |
||||
<div v-else-if="file.value.stat === 'error'" class="flex items-center"> |
||||
<img src="/icons/error.svg" class="h-10"> |
||||
</div> |
||||
</div> |
||||
<p class="font-semibold">{{ empty }}</p> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup> |
||||
import { ref, computed } from 'vue'; |
||||
import File from '@/file'; |
||||
import Utils from '@/utils'; |
||||
import StorageS3 from '@/storageS3'; |
||||
|
||||
const props = defineProps({ |
||||
name: { type: String }, |
||||
files: { |
||||
type: Array, |
||||
default: [], |
||||
}, |
||||
accept: { |
||||
type: String, |
||||
default: '*', |
||||
}, |
||||
multiple: { |
||||
type: Boolean, |
||||
default: true, |
||||
}, |
||||
disabled: { |
||||
type: Boolean, |
||||
default: false, |
||||
}, |
||||
empty: { |
||||
type: String, |
||||
default: 'Ajouter un/des fichier(s)', |
||||
} |
||||
}); |
||||
|
||||
const emit = defineEmits(['file-added', 'file-removed']); |
||||
const files = ref([]); |
||||
|
||||
props.files.forEach(file => files.value.push(ref(file))); |
||||
|
||||
defineExpose({ removeFiles: () => files.value = [] }); |
||||
|
||||
const empty = computed(() => { |
||||
return files.value.length ? "" : props.empty; |
||||
}); |
||||
|
||||
const removeFile = (e) => { |
||||
Utils.Prevent(e); |
||||
const file = files.value.filter(f => f.value.name == e.target.dataset.name)[0]; |
||||
files.value = files.value.filter(f => f.value.name != file.value.name); |
||||
emit("file-removed", file.value); |
||||
} |
||||
|
||||
const addFile = (file) => { |
||||
if(!files.value.filter(fileStat => fileStat.value.name == file.name).length) |
||||
{ |
||||
const fileStat = ref({ |
||||
name: file.name, |
||||
size: file.size, |
||||
done: 0, |
||||
stat: "loading", |
||||
key: "", |
||||
url: "" |
||||
}); |
||||
files.value.push(fileStat); |
||||
StorageS3.UploadFile(file, { |
||||
onUpdate: (done) => |
||||
{ |
||||
console.log("Uploading : " + done + "% done"); |
||||
fileStat.value.done = done; |
||||
}, |
||||
onSuccess: async (key) => { |
||||
console.log("Uploaded : " + key); |
||||
fileStat.value.key = key; |
||||
fileStat.value.stat = "success"; |
||||
const { url } = await StorageS3.GenerateSignedUrl(key); |
||||
fileStat.value.url = url; |
||||
emit("file-added", fileStat.value); |
||||
}, |
||||
onError: (e) => { |
||||
console.log("Failed upload : " + e); |
||||
fileStat.value.stat = "error"; |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
const fileSelected = (e) => { [...e.target.files].forEach(file => addFile(file)); } |
||||
|
||||
const openDialog = () => { document.querySelector('#dz-input-' + props.name).click(); } |
||||
const dropFiles = (e) => { |
||||
Utils.Prevent(e); |
||||
let popup = document.querySelector(`#dz-popup-${props.name}`); |
||||
if(!popup.classList.contains("hidden")) popup.classList.add("hidden"); |
||||
[...e.dataTransfer.items].forEach((item) => { |
||||
if (item.kind === "file" && (!files.value.length || props.multiple === "true")) { |
||||
const file = item.getAsFile(); |
||||
if(!files.value.length || props.multiple === "true") addFile(file); |
||||
} |
||||
}); |
||||
|
||||
} |
||||
const popupShow = (e) => { Utils.Prevent(e); document.querySelector(`#dz-popup-${props.name}`).classList.toggle("hidden"); } |
||||
const popupClose = (e) => { Utils.Prevent(e); document.querySelector(`#dz-popup-${props.name}`).classList.toggle("hidden"); } |
||||
</script> |
@ -0,0 +1,81 @@ |
||||
<script setup> |
||||
import { Head, Link } from '@inertiajs/vue3'; |
||||
import GuestLayout from '@/Layouts/GuestLayout.vue'; |
||||
import Dropzone from '@/Components/Dropzone.vue'; |
||||
import Create from './Partials/Create.vue'; |
||||
import Show from'./Partials/Show.vue'; |
||||
import Modal from './Partials/Modal.vue'; |
||||
import { reactive, ref } from 'vue'; |
||||
|
||||
const props = defineProps({ |
||||
photos: { |
||||
type: Array, |
||||
default: [] |
||||
}, |
||||
active: false |
||||
}); |
||||
|
||||
const create = reactive({ active: false }); |
||||
|
||||
const fullScreenState = reactive({ photo: {}, active: false }); |
||||
|
||||
const fullScreen = (photo) => { |
||||
fullScreenState.photo = photo; |
||||
fullScreenState.active = true; |
||||
} |
||||
|
||||
const gridState = reactive({ columns: 3 }); |
||||
</script> |
||||
|
||||
<template> |
||||
<Head title="Photos"/> |
||||
|
||||
<Modal v-if="fullScreenState.active" :photoId="fullScreenState.photo" :photos="props.photos" |
||||
@close="() => { fullScreenState.photo = {}; fullScreenState.active = false; }" |
||||
/> |
||||
<GuestLayout> |
||||
<template #header> |
||||
<div class="w-full flex justify-between items-center py-1"> |
||||
<div class="relative flex items-center"> |
||||
<p class="font-semibold mr-4">Affichage</p> |
||||
<div class="flex items-center bg-white rounded-md shadow-sm shadow-gray-300 overflow-hidden"> |
||||
<button @click="gridState.columns = 3" class="flex items-center h-full border-r border-gray-400 p-1 |
||||
hover:bg-black/5"> |
||||
<img src="/icons/block-content.svg" class="h-7"> |
||||
</button> |
||||
<button @click="gridState.columns = 1" class="flex items-center h-full border-r border-gray-400 p-1 |
||||
hover:bg-black/5"> |
||||
<img src="/icons/list.svg" class="h-7"> |
||||
</button> |
||||
<button @click="() => fullScreen(props.photos[0].uuid)" class="flex items-center p-1 |
||||
hover:bg-black/5"> |
||||
<img src="/icons/slider.svg" class="h-7"> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div class="relative flex items-center"> |
||||
|
||||
<button @click="create.active = !create.active" class="flex items-center"> |
||||
<p class="font-semibold mr-4">Ajouter une photo</p> |
||||
<img src="/icons/add.svg" class="h-8"> |
||||
</button> |
||||
<Create @close="create.active = !create.active" v-if="create.active" class="absolute -right-0 top-[110%] z-10 mt-4" /> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
<template #content> |
||||
<div class="w-full px-[17.5%] min-h-screen"> |
||||
<div class="w-full h-full bg-black/10 py-20 px-1"> |
||||
<div :class="{'grid-cols-3': gridState.columns === 3}" |
||||
class="w-full grid"> |
||||
<Show v-for="(photo, index) in props.photos" |
||||
:photo="photo" :index="index" :length="props.photos.length" :columns="gridState.columns" |
||||
@full-screen="fullScreen" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
</div> |
||||
</template> |
||||
</GuestLayout> |
||||
</template> |
@ -0,0 +1,81 @@ |
||||
<script setup> |
||||
import { Head, Link, useForm } from '@inertiajs/vue3'; |
||||
import File from '@/file'; |
||||
import GuestLayout from '@/Layouts/GuestLayout.vue'; |
||||
import Dropzone from '@/Components/Dropzone.vue'; |
||||
import TextInput from '@/Components/TextInput.vue'; |
||||
import InputError from '@/Components/InputError.vue'; |
||||
import { onMounted, reactive, ref } from 'vue'; |
||||
|
||||
const dropzone = ref(null); |
||||
|
||||
const imageState = reactive({ |
||||
"url": "", |
||||
}) |
||||
|
||||
const form = useForm({ |
||||
name: "", |
||||
path: "", |
||||
}) |
||||
|
||||
const emits = defineEmits(["close"]); |
||||
|
||||
const imageAdded = (file) => { |
||||
form.path = file.key; |
||||
imageState.url = file.url; |
||||
if(form.name === "") form.name = File.Basename(file.key).split("_")[1]; |
||||
} |
||||
|
||||
const imageRemoved = (file) => { |
||||
form.path = ""; |
||||
} |
||||
|
||||
const submit = () => { |
||||
form.post("/photo", { |
||||
headers: { |
||||
"Content-Type": "application/json", |
||||
"X-CSRF-Token": document.querySelector('input[name=_token]').value, |
||||
}, |
||||
}); |
||||
|
||||
imageState.url = ""; |
||||
form.path = ""; |
||||
form.name = ""; |
||||
dropzone.value.removeFiles(); |
||||
emits("close"); |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="text-left text-black w-[30rem] shadow-lg shadow-gray-400 rounded-lg bg-gray-50 overflow-hidden |
||||
border border-gray-300"> |
||||
<div v-if="imageState.url" class="max-h-72 overflow-hidden flex items-center"> |
||||
<img :src="imageState.url"> |
||||
</div> |
||||
<div class="px-10 pb-5 pt-5"> |
||||
<div @click="() => emits('close')" class="w-full flex justify-end "> |
||||
<img src="/icons/cancel.svg" class="h-5"> |
||||
</div> |
||||
<form @submit.prevent="submit"> |
||||
<p>Fichier image</p> |
||||
<Dropzone |
||||
ref="dropzone" |
||||
@file-added="imageAdded" |
||||
@file-removed="imageRemoved" |
||||
:name="photos" |
||||
:empty="'Téléversé une image'" |
||||
:multiple="false" |
||||
:accept="'image/*'" |
||||
:class="'mb-1'" |
||||
/> |
||||
<InputError :message="form.errors.path"/> |
||||
<p class="mt-3">Nom</p> |
||||
<TextInput :class="'mb-1 w-full'" v-model="form.name" required :placeholder="'Nom de la photo'"/> |
||||
<InputError :message="form.errors.name"/> |
||||
<div class="w-full flex justify-end mt-3"> |
||||
<button type="submit" class="text-white font-semibold p-1 px-2 bg-primary rounded-md">Ajouter</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</template> |
@ -0,0 +1,40 @@ |
||||
<script setup> |
||||
import TextInput from '@/Components/TextInput.vue'; |
||||
import { useForm } from '@inertiajs/vue3'; |
||||
|
||||
const props = defineProps({ |
||||
photo: { |
||||
type: Object, |
||||
required: true, |
||||
} |
||||
}) |
||||
|
||||
const form = useForm({ |
||||
name: "" |
||||
}); |
||||
|
||||
const emits = defineEmits(["close"]); |
||||
|
||||
const submit = () => { |
||||
form.post("/photo/" + props.photo.uuid , { |
||||
headers: { |
||||
"Content-Type": "application/json", |
||||
"X-CSRF-Token": document.querySelector('input[name=_token]').value, |
||||
}, |
||||
}); |
||||
form.name = ""; |
||||
emits("close"); |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="text-left text-black shadow-md shadow-gray-500 rounded-lg |
||||
bg-gray-50 overflow-hidden border border-gray-300 p-2 w-72" > |
||||
<form @submit.prevent="submit"> |
||||
<TextInput :placeholder="'Changer le nom de la photo'" :class="'w-full'" v-model="form.name"/> |
||||
<div class="w-full flex mt-3"> |
||||
<button type="submit" class="text-white font-semibold p-1 px-2 bg-primary rounded-md">Changer</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</template> |
@ -0,0 +1,81 @@ |
||||
<script setup> |
||||
import { reactive } from 'vue'; |
||||
|
||||
|
||||
const props = defineProps({ |
||||
photoId: { |
||||
type: String, |
||||
required: true, |
||||
}, |
||||
photos: { |
||||
type: Array, |
||||
required: true |
||||
} |
||||
}); |
||||
|
||||
const photo = reactive({ uuid: props.photoId, path: props.photos.filter(photo => photo.uuid == props.photoId)[0].path }); |
||||
|
||||
const emit = defineEmits(['close']); |
||||
|
||||
const nextPhoto = () => { |
||||
let nextPhoto = { uuid: props.photos[0].uuid, path: props.photos[0].path }; |
||||
let check = false; |
||||
props.photos.every((ph) => { |
||||
if(check) { |
||||
nextPhoto = ph; |
||||
return false; |
||||
} |
||||
check = ph.uuid == photo.uuid; |
||||
return true; |
||||
}); |
||||
photo.path = nextPhoto.path; |
||||
photo.uuid = nextPhoto.uuid; |
||||
} |
||||
|
||||
const previousPhoto = () => { |
||||
let nextPhoto = { uuid: props.photos[props.photos.length - 1].uuid, path: props.photos[props.photos.length - 1].path }; |
||||
let check = false; |
||||
props.photos.reverse().every((ph) => { |
||||
if(check) { |
||||
nextPhoto = ph; |
||||
return false; |
||||
} |
||||
check = ph.uuid == photo.uuid; |
||||
return true; |
||||
}); |
||||
props.photos.reverse(); |
||||
photo.path = nextPhoto.path; |
||||
photo.uuid = nextPhoto.uuid; |
||||
} |
||||
|
||||
const close = () => { emit('close'); }; |
||||
|
||||
const closeOnEscape = (e) => { |
||||
if (e.key === 'Escape' && props.show) { |
||||
close(); |
||||
} |
||||
}; |
||||
|
||||
</script> |
||||
|
||||
<template> |
||||
<div @keypress="closeOnEscape" id="modal-image" class="fixed z-40 modal-base top-0 bottom-0 left-0 right-0 bg-black/90"> |
||||
<div class="w-full h-full flex items-center justify-center z-50"> |
||||
<button @click="previousPhoto" class="pr-3 hover:scale-105"> |
||||
<img src="/icons/next.svg" class="h-16 invert rotate-180"> |
||||
</button> |
||||
<div class="relative h-4/5 max-h-5xl"> |
||||
<div class="absolute right-0 p-4"> |
||||
<button @click="close" class="bg-red-600 shadow-md shadow-gray-600 p-1 rounded-md"> |
||||
<img src="/icons/cancel.svg" class="h-8 invert pointer-events-none"> |
||||
</button> |
||||
</div> |
||||
<img :src="photo.path" class="h-full"> |
||||
</div> |
||||
<button @click="nextPhoto" class="pl-3 hover:scale-105"> |
||||
<img src="/icons/next.svg" class="h-16 invert"> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
</template> |
@ -0,0 +1,75 @@ |
||||
<script setup> |
||||
import { Link } from '@inertiajs/vue3'; |
||||
import { useForm } from '@inertiajs/vue3'; |
||||
import { reactive } from 'vue'; |
||||
import Edit from './Edit.vue'; |
||||
import axios from 'axios'; |
||||
|
||||
const props = defineProps({ |
||||
photo: { |
||||
type: Object, |
||||
required: true, |
||||
}, |
||||
index: { |
||||
type: Number, |
||||
required: true, |
||||
}, |
||||
length: { |
||||
type: Number, |
||||
required: true, |
||||
}, |
||||
columns: { |
||||
type: Number, |
||||
default: 3, |
||||
}, |
||||
}); |
||||
|
||||
const form = useForm(); |
||||
|
||||
const photoState = reactive({ edit: false }); |
||||
|
||||
const emits = defineEmits(["full-screen"]); |
||||
|
||||
const deletePhoto = async () => { |
||||
if(confirm("Voulez-vous vraiment supprimé cette photo")){ |
||||
form.delete("/photo/" + props.photo.uuid, { |
||||
headers: { |
||||
"X-CSRF-Token": document.querySelector('input[name=_token]').value, |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div :class="{ |
||||
'border-r': (props.index + 1) % props.columns != 0, |
||||
'border-b': props.index < props.length - props.columns, |
||||
'h-96': props.columns === 3, |
||||
'h-[25rem]': props.columns !== 3, |
||||
}" |
||||
class="group relative w-full overflow-hidden border-white hover:scale-[1.003] flex items-center bg-black/90"> |
||||
<div class="hidden absolute left-0 right-0 top-0 p-2 group-hover:flex justify-between"> |
||||
<div class="flex items-center"> |
||||
<button @click="() => emits('full-screen', props.photo.uuid)" class="bg-black/50 p-1 rounded-md mr-2"><img src="/icons/full-screen.svg" class="h-6 invert"></button> |
||||
<div class="relative"> |
||||
<button @click="photoState.edit = !photoState.edit" class="bg-black/50 p-1 rounded-md mr-2"><img src="/icons/modify.svg" class="h-6 invert"></button> |
||||
<Edit v-if="photoState.edit" |
||||
@close="() => photoState.edit = false" |
||||
:photo="props.photo" |
||||
:class="'absolute left-0 top-full mt-2'" |
||||
/> |
||||
</div> |
||||
<button @click="deletePhoto" class="bg-red-600 p-1 rounded-md"><img src="/icons/delete.png" class="h-6 invert"></button> |
||||
</div> |
||||
<div class="text-right bg-black/30 p-1 px-3 rounded-md"> |
||||
<p class="text-sm text-white">{{ props.photo.name }}</p> |
||||
<p class="text-sm text-white">publier par {{ props.photo.user.name }}</p> |
||||
</div> |
||||
</div> |
||||
<div class="hidden absolute left-0 right-0 bottom-0 p-2 group-hover:flex justify-end"> |
||||
<a :href="props.photo.path" target="_blank" class="bg-black/50 p-1 rounded-md"><img src="/icons/download.png" class="h-6 invert"></a> |
||||
</div> |
||||
<img :src="props.photo.path" class="w-full bg-white"> |
||||
</div> |
||||
</template> |
@ -0,0 +1,50 @@ |
||||
export default class File { |
||||
static SizeToString(size)
|
||||
{ |
||||
let cpt = 0; |
||||
let units = ["octet", "Ko", "Mo", "Go", "To"]; |
||||
while(size >= 1000 && size >= 1) |
||||
{ |
||||
size = size / 1000; |
||||
cpt++; |
||||
} |
||||
return (Math.ceil(size * 100) / 100) + " " + units[cpt]; |
||||
} |
||||
|
||||
static Icon(ext) |
||||
{ |
||||
let img = "/icons/fichier.png"; |
||||
switch(ext.toLowerCase()) { |
||||
case 'pdf': return "/icons/pdf.png"; |
||||
case 'dwg': return "/icons/dwg.png"; |
||||
case 'stp': |
||||
case 'step': return "/icons/step.png"; |
||||
case 'xlsx': return "/icons/excel.png"; |
||||
case 'docx': return "/icons/word.png"; |
||||
case 'zip': |
||||
case '7z': |
||||
case 'rar': |
||||
case 'gz': return "/icons/zip.png"; |
||||
case "unitypackage": return "/icons/unity.svg"; |
||||
case "png": |
||||
case "jfif": |
||||
case "jpeg": |
||||
case "jpg": |
||||
case "gif": |
||||
case "svg": |
||||
case "webp": return "/icons/photo.svg"; |
||||
} |
||||
return img; |
||||
} |
||||
|
||||
static Basename(filename) |
||||
{ |
||||
return filename.substring(filename.lastIndexOf('/') > 0 ? filename.lastIndexOf('/') + 1 : 0, filename.lastIndexOf('.')) || filename; |
||||
} |
||||
|
||||
static Extension(filename) |
||||
{ |
||||
return filename.substring(filename.lastIndexOf('.')+1, filename.length) || filename; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,106 @@ |
||||
export default class StorageS3 |
||||
{ |
||||
static options =
|
||||
{ |
||||
genUrl: "/s3/generate-presigned-url", |
||||
startMultipartUrl: "/s3/start-multipart-upload", |
||||
genMultipartUrl: "/s3/generate-presigned-multipart-url", |
||||
completeMultipartUrl: "/s3/complete-multipart-upload", |
||||
proxyMultipartUrl: "/s3/proxy-multipart-upload", |
||||
partSize: 32 * 1024 * 1024, |
||||
} |
||||
|
||||
static async UploadFile(file, callbacks = { |
||||
onUpdate: async (done) => {},
|
||||
onSuccess: async () => {}, |
||||
onError: async () => {} |
||||
})
|
||||
{ |
||||
try { |
||||
const uploadPromises = []; |
||||
let data = new Uint8Array(await file.arrayBuffer()); |
||||
const { uploadId, key } = await StorageS3.StartMultiPartUpload(file.name); |
||||
let partIndex = 0; |
||||
for (let start = 0; start < file.size; start += StorageS3.options.partSize) |
||||
{ |
||||
partIndex++; |
||||
let partNumber = partIndex; |
||||
let length = start + StorageS3.options.partSize < file.size ? start + StorageS3.options.partSize : file.size; |
||||
if(callbacks.onUpdate) await callbacks.onUpdate((length/file.size) * 100); |
||||
await new Promise(r => setTimeout(r, 200)); |
||||
const partData = data.subarray(start, length); |
||||
uploadPromises.push( |
||||
StorageS3.GenerateMultipartSignedUrl(key, uploadId, partNumber, partData.length) |
||||
.then(({ url }) => StorageS3.UploadPart(url, partData, partNumber)) |
||||
.then(({ ETag }) => ({ ETag, PartNumber: partNumber })) |
||||
); |
||||
} |
||||
let parts = await Promise.all(uploadPromises); |
||||
parts = parts.sort((a, b) => a.PartNumber - b.PartNumber); |
||||
await StorageS3.CompleteMultiPartUpload(key, uploadId, parts); |
||||
if(callbacks.onSuccess) await callbacks.onSuccess(key); |
||||
} catch(e) { if(callbacks.onError) await callbacks.onError(e); } |
||||
} |
||||
|
||||
static async StartMultiPartUpload(key) |
||||
{ |
||||
const response = await fetch(`${StorageS3.options.startMultipartUrl}?filename=${encodeURIComponent(key)}`, { |
||||
method: "GET", |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
}); |
||||
return response.json(); |
||||
} |
||||
|
||||
static async GenerateMultipartSignedUrl(key, uploadId, partNumber, partLength) |
||||
{ |
||||
const response = await fetch(`${StorageS3.options.genMultipartUrl}?key=${encodeURIComponent(key)}&uploadId=${uploadId}&partNumber=${partNumber}&partLength=${partLength}`, { |
||||
method: "GET", |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
}); |
||||
return response.json();
|
||||
} |
||||
|
||||
static async GenerateSignedUrl(key) |
||||
{ |
||||
const response = await fetch(`${StorageS3.options.genUrl}?key=${encodeURIComponent(key)}`, { method: "GET" }); |
||||
return response.json();
|
||||
} |
||||
|
||||
static async UploadPart(signedUrl, partData, partNumber) |
||||
{ |
||||
const response = await fetch(`${StorageS3.options.proxyMultipartUrl}`, { |
||||
method: 'PUT', |
||||
headers: { |
||||
"X-CSRF-Token": document.querySelector('input[name=_token]').value, |
||||
"X-SignedUrl": signedUrl, |
||||
"Content-Length": partData.length, |
||||
}, |
||||
body: partData, |
||||
}); |
||||
if (!response.ok) { |
||||
throw new Error(`Failed to upload part: ${partNumber}`); |
||||
} |
||||
return response.json(); // Returns ETag
|
||||
} |
||||
|
||||
static async CompleteMultiPartUpload(key, uploadId, parts) |
||||
{ |
||||
const response = await fetch(StorageS3.options.completeMultipartUrl, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
"X-CSRF-Token": document.querySelector('input[name=_token]').value, |
||||
}, |
||||
body: JSON.stringify({ |
||||
key, |
||||
uploadId, |
||||
parts, |
||||
}), |
||||
}); |
||||
return response.json(); |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
export default class Utils { |
||||
static Prevent(e) { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
} |
||||
} |