Initial commit

This commit is contained in:
2026-05-20 18:25:15 +02:00
commit 95f0b4ff01
288 changed files with 90909 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
/** @typedef { import('types/CreditsObject.js').CreditsObject} CreditsObject */
export function Credits(){
return {
/**
* Credits. Preloaded at startup.
* @type {CreditsObject[]}
*/
credits: [],
/**
*
* @param {string|null} jsonCredits
*/
init( jsonCredits= null ){
if( jsonCredits !== null ){
this.credits = JSON.parse(jsonCredits);
}
},
/**
* Add an empty credit.
*/
addEmptyCredits() {
this.credits.push({name: '', description: ''});
},
/**
* Remove a specific credits.
* @param {number} index index of credit.
*/
removeCredits( index ){
this.credits.splice(index, 1);
}
}
}

View File

@@ -0,0 +1,154 @@
/** @typedef { import('types/UploadchunkResponse.js').UploadchunkResponse} UploadchunkResponse */
export const CHUNK_SIZE = 8192;
/**
* An uploaded file instance.
* Create a new file data.
*
* @param {string} name Filename
* @param {number} totalChunks Total number of chunks of the file.
* @param rawFile The JS file element relation.
*/
export function FSFileData(name, totalChunks, rawFile ) {
return {
/**
* Filename.
* @type {string}
*/
name,
/**
* Number of total chunks based on CHUNK_SIZE.
* @type {number}
*/
totalChunks,
/**
* The JS file element relation.
*/
rawFile,
/**
* Upload progression value.
* @type {number}
*/
progressValue: 0,
/**
* Current chunk uploaded.
* @type {number}
*/
currentChunk: 0,
/**
* If the upload of the file is finished.
* @type {boolean}
*/
done: false,
/**
* If there is an error during file uploading.
* @type {any|null}
*/
error: null,
/**
* UUID v4 for the file.
* @type {`${string}-${string}-${string}-${string}-${string}`}
*/
uuid: crypto.randomUUID(),
/**
* Look if this file is currently uploading.
* @returns {boolean}
*/
get isUploading()
{
return !this.done && !this.error;
},
/**
* Build API url.
* @param {string} section
* @returns {string} The API url.
*/
buildUrl(section)
{
return `/api/fs/upload-chunk/${section}`;
},
/**
* Upload the file.
* @param {string} section section of the file.
* @returns {Promise<void>}
*/
async upload(section)
{
if (!this.rawFile)
return; // Can't upload in that case.
/**
* Get CSRF token for uploading request.
* @type {string}
*/
const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? '';
for (let i = 0; i < this.totalChunks; i++) {
if (this.error)
return; // Abort the process.
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, this.rawFile.size);
const chunk = this.rawFile.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('file_uuid', this.uuid);
formData.append('current_chunk', i);
formData.append('total_chunks', this.totalChunks);
formData.append('filename', this.rawFile.name);
formData.append('_token', CSRF);
// -----
// UPLOAD TIME !
// -----
try {
const RESPONSE = await fetch(this.buildUrl(section), {method: 'POST', body: formData});
if (!RESPONSE.ok) // Problem with the request.
throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`);
/** @type {UploadchunkResponse} */
const DATA = await RESPONSE.json();
if (DATA.success !== true || DATA.uploaded !== true)
// The request reached the file server but could not be sent.
throw new Error(`${DATA.error}`);
this.currentChunk = i + 1;
this.progressValue = Math.round(((i + 1) / this.totalChunks) * 100);
if (DATA.finished === true) {
this.done = true;
return;
}
} catch (err) {
this.error = 'Error on chunk ' + (i + 1) + '. ' + err.message;
this.progressValue = 0;
return;
}
}
}
}
}

View File

@@ -0,0 +1,133 @@
import { FSFileData, CHUNK_SIZE } from "./FSFileData.js";
/**
* File uploader on FileServer.
* @returns {{files: Array<FSFileData>, section: string, init(): void, readonly numberOfFiles: number, readonly isUploading: boolean, readonly hasErrors: boolean, readonly allFilesUploaded: boolean, totalChunksNumber(*): number, handleSubmitFile(Event): Promise<void>, handleRetryFile(number): Promise<void>, removeFile(number): void}|boolean|this is FSFileData[]|number|boolean}
* @constructor
*/
export function FSUploader(){
return {
/**
* Array of uploaded files.
* @type {Array<FSFileData>}
*/
files: [],
/**
* Section that files must be uploaded.
* @type {string}
*/
section: document.querySelector("meta[name='fs-section']")?.content ?? '',
/**
* Triggered in fs-upload.blade.php
* Refresh icons.
*
* @param {Array<FSFileData>} oldFilesArray
*/
init( oldFilesArray ){
this.$watch('files', () =>{
this.$nextTick(() => window.refreshIcons(this.$el) )
})
if( oldFilesArray !== undefined && oldFilesArray.length > 0)
this.files = oldFilesArray;
},
/**
* Shortcut to files.length.
* @returns {number}
*/
get numberOfFiles(){
return this.files.length
},
/**
* Look if some files are currently in upload.
* @returns {boolean}
*/
get isUploading(){
return this.files.some(
file => file.isUploading
);
},
/**
* Check if some files have an error or not.
* @returns {boolean}
*/
get hasErrors(){
return this.files.some(
file => file.error
);
},
/**
* Check if all files are uploaded.
* True if all files are uploaded or no one.
*
* @returns {boolean}
*/
get allFilesUploaded(){
return ( this.numberOfFiles === 0 || this.files.every(file => file.done) );
},
/**
* Get total chunks size of a raw file.
* @param rawFile
* @returns {number}
*/
totalChunksNumber( rawFile ){
return Math.ceil( rawFile.size / CHUNK_SIZE );
},
/**
* Handle file submission.
* You can submit multiple files at once.
*
* @param {Event} e
* @returns {Promise<void>}
*/
async handleSubmitFile( e ){
const RAW_FILES_FROM_EVENT = Array.from( e.target.files );
e.target.value = ''; // Default.
for( const RAW_FILE of RAW_FILES_FROM_EVENT ){
const TOTAL_CHUNKS = this.totalChunksNumber(RAW_FILE);
let fsData = FSFileData( RAW_FILE.name, TOTAL_CHUNKS, RAW_FILE );
this.files.push( fsData );
await this.files[this.files.length - 1].upload(this.section);
}
},
/**
* Retry file uploading.
*
* @param {number} index FSFileData index in this.files.
* @returns {Promise<void>}
*/
handleRetryFile( index ){
const OLDFSFILE = this.files[index];
let fsData = FSFileData(OLDFSFILE.name, this.totalChunksNumber(OLDFSFILE.rawFile), OLDFSFILE.rawFile );
this.files[index] = fsData;
this.files[index].upload(this.section);
},
/**
* Remove a file.
* @param {number} index FSFileData index in this.files.
*/
handleRemoveFile( index ){
this.files.splice(index, 1);
}
}
}

View File

@@ -0,0 +1,127 @@
import { MainImageManager as GalleryImage } from "./MainImageManager.js";
const MAX_GALLERY = 20;
export function GalleryManager() {
return {
/**
* All images uploaded.
* @type {Array<GalleryImage>}
*/
images: [],
/**
* Forward to this.images.length
* @returns {number}
*/
get number(){
return this.images.length;
},
/**
* Verify if all images has been uploaded or not.
* @return {boolean}
*/
get allUploaded(){
for(const IMG of this.images){
if( IMG.serverFilePath == null ){
return false;
}
}
return true;
},
/**
* Return if there is an error on an image or not.
*
* @returns {null|Array<string>}
*/
get error(){
const RESPONSE = [];
for( const IMG of this.images ){
if( IMG.error !== null )
RESPONSE.push( IMG.error );
}
if( RESPONSE.length === 0 )
return null;
return RESPONSE;
},
/**
* Superior or equal than 20.
* @returns {boolean}
*/
get isFull(){
return this.images.length >= MAX_GALLERY;
},
/**
* Reload image if there is an error or edition.
*
* @param {Array<string>} oldPaths
*/
init( oldPaths = null ){
if( oldPaths === null || oldPaths.length <= 0 )
return;
for( const PATH of oldPaths ){
if( this.isFull )
break;
const IMG = GalleryImage();
IMG.getOldImage( PATH );
this.images.push(IMG);
}
},
/**
* Upload images, refresh the preview and store images if there is a problem.
*
* @param {Event} e
* @returns {Promise<void>}
*/
async handleSubmitFiles(e){
const FILES = e.target.files;
if( !FILES || FILES.length <= 0 )
return; // No file uploaded.
for( const FILE of FILES ){
if( this.isFull )
break;
const IMG = GalleryImage();
IMG.name = FILE.name;
IMG.type = FILE.type;
this.images.push(IMG);
const IMG_FROM_LIST = this.images[this.images.length - 1];
await IMG_FROM_LIST.uploadImageToTemporary( FILE );
await new Promise( resolve => {
const READER = new FileReader();
READER.onload = (e2) => {
IMG_FROM_LIST.preview = e2.target.result;
resolve();
}
READER.onerror = () => resolve();
READER.readAsDataURL(FILE);
});
}
},
/**
* Remove specific file.
* @param {number} index
*/
handleRemoveFile(index){
this.images[index].handleRemoveFile(null);
this.images.splice(index, 1);
}
}
}

View File

@@ -0,0 +1,38 @@
/**
* Handle new game already selected.
*
* @param {Object} initialContent With gameName, gamePlatformId and gameGenreId field.
* @returns {Object}
*/
export function GameSelector(){
return {
/**
* Game Name
* @type {string|null}
*/
name: null,
/**
* Game Platform Id.
* @type {number|null}
*/
platformId: null,
/**
* Game genre Id.
* @type {number|null}
*/
genreId: null,
/**
* Initialize game selector.
* @param initialContent
*/
init( initialContent = {} ){
this.name = initialContent.name ?? null;
this.platformId = Number(initialContent.platformId) ?? null;
this.genreId = Number(initialContent.genreId) ?? null;
}
}
}

View File

@@ -0,0 +1,60 @@
import { calculate as calculateHashes } from "../hashes.js";
export function HashesManager( wire ) {
return {
/**
* Wire variable instance.
*/
$wire: wire,
/**
* If a file hash is currently calculated or not.
* @type {boolean}
*/
isCalculating: false,
/**
* An error on hash calculation.
* @type {any|null}
*/
error: null,
async handleSubmitFile(){
if( this.isCalculating === true ) // Calculation already done for another file.
return;
this.error = null; // Reset.
const FILE = await this.openFileInput();
if( !FILE )
return; // No file sent.
this.isCalculating = true;
try {
const RESULT = await calculateHashes(FILE);
await this.$wire.addHash(RESULT.filename, RESULT.crc32, RESULT.sha1); // Send a signal to livewire.
} catch(err) {
this.error = err.message;
} finally {
this.isCalculating = false;
}
},
/**
* Open a specific file.
* @returns {Promise<unknown>}
*/
async openFileInput(){
return new Promise(resolve => {
const input = document.createElement("input");
input.type = "file";
input.onchange = () => resolve(input.files[0] ?? null );
input.oncancel = () => resolve(null);
input.click();
});
}
}
}

View File

@@ -0,0 +1,151 @@
export function MainImageManager() {
return {
/**
* If an image has been uploaded or not.
* @type {boolean}
*/
uploaded: false,
/**
* Actual image path on the server.
* @type {string|null}
*/
serverFilePath: null,
/**
* Image filename.
* @type {string|null}
*/
name: null,
/**
* Image filetype.
* @type {string|null}
*/
type: null,
/**
* Handle preview.
* @type {unknown}
*/
preview: null,
/**
* Current error message.
* @type {string}
*/
error: null,
/**
* Reload image if there is an error.
*
* @param {string|null} oldPath If there is already a path.
*/
init( oldPath = null ){
if(oldPath === "" || oldPath === null)
return;
this.getOldImage(oldPath)
},
/**
* Get old image from old path and refresh preview.
* @param {string} oldPath
* @param {function|null} callback Used in the gallery to push the image at the right time.
*/
getOldImage( oldPath, callback = null ) {
this.readOldImage( '/storage/' + oldPath ).then( blob => {
this.uploaded = true;
this.serverFilePath = oldPath;
const READER = new FileReader();
READER.onload = () => {
this.preview = READER.result;
}
READER.readAsDataURL(blob);
});
},
/**
* Get old image data.
* @param {string} url
* @returns {Promise<Blob>}
*/
async readOldImage(url){
const RESPONSE = await fetch(url);
return await RESPONSE.blob();
},
async uploadImageToTemporary( file ){
const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? '';
const URL = `/api/tempfile/upload`;
const formData = new FormData();
formData.append('file', file);
formData.append('_token', CSRF );
try {
const RESPONSE = await fetch( URL, { method: 'POST', body: formData } );
if( !RESPONSE.ok ) // Problem with the request.
throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`);
const DATA = await RESPONSE.json();
if( DATA.path === null ){
throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`);
}
this.serverFilePath = DATA.path;
this.uploaded = true;
} catch (err){
this.error = 'Error on main image uploading ' + ( i + 1 ) + '. ' + err.message;
console.error( this.error );
return;
}
},
/**
* Upload image, refresh the preview and store image if there is a problem.
*
* @param {Event} e
*/
async handleSubmitFile(e) {
const FILE = e.target.files[0];
if (!FILE)
return; // No file uploaded.
this.handleRemoveFile(null);
await this.uploadImageToTemporary(FILE);
this.name = FILE.name;
this.type = FILE.type;
const READER = new FileReader();
READER.onload = (e2) => {
this.preview = e2.target.result;
}
READER.readAsDataURL(FILE);
},
/**
* Remove main image.
* @param {Event} e
*/
handleRemoveFile(e){
this.uploaded = false;
this.serverFilePath = null;
this.preview = null;
this.name = null;
this.type = null;
this.error = null;
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* @typedef {Object} CreditsObject
*
* @property {string} name Credits name.
* @property {string} description Credits description.
*/
/**
* If this object is a credit object or not.
*
* @param {object} object
* @returns {boolean}
*/
export function isACreditsObject( object ){
return typeof object === 'object' && object.name !== undefined && object.description !== undefined;
}
export {}

View File

@@ -0,0 +1,13 @@
/**
* @typedef {Object} UploadchunkResponse
*
* @see app/Http/FileServerController.php
* @external RHPZFS::src/Endpoints/Uploadchunk
*
* @property {number} chunk The current chunk that has been uploaded.
* @property {number} total_chunks Number of total chunks.
* @property {boolean} uploaded If the chunk has been correctly uploaded.
* @property {Object|boolean} file If the file has been entirely uploaded or not.
* @property {boolean} finished Added by main server. Indicates if the file upload is finished or not.
*/
export {}

23
resources/js/app.js Normal file
View File

@@ -0,0 +1,23 @@
import { createIcons, icons } from "lucide";
import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import { calculate as calculateHashes } from "./hashes.js";
// Lucide icons.
createIcons({ icons });
window.refreshIcons = (container = document) => {
const pending = container.querySelectorAll('[data-lucide]');
if (pending.length === 0) return;
createIcons({ icons });
};
// EasyMDE.
window.EasyMDE = EasyMDE;
// Hashes.
window.calculateHashes = calculateHashes;

56
resources/js/hashes.js Normal file
View File

@@ -0,0 +1,56 @@
function createCrcTable() {
const table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[n] = c;
}
return table;
}
async function crc32(buffer) {
let crc = 0 ^ (-1);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ bytes[i]) & 0xFF];
}
return ((crc ^ (-1)) >>> 0).toString(16).padStart(8, '0');
}
async function sha1(buffer) {
const hashBuffer = await crypto.subtle.digest('SHA-1', buffer);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
const crcTable = createCrcTable();
export async function calculate( file ){
return new Promise( ( resolve, reject ) => {
const freader = new FileReader();
freader.onload = async e => {
try {
const buffer = e.target.result;
const crc32V = await crc32(buffer);
const sha1V = await sha1(buffer);
resolve({
filename: file.name,
crc32: crc32V,
sha1: sha1V
});
} catch (error) {
reject(error);
}
}
freader.onerror = () => {
reject( new Error(`Could not parse file: ${file.name}`) );
}
freader.readAsArrayBuffer(file);
})
}

387
resources/js/submissions.js Normal file
View File

@@ -0,0 +1,387 @@
import { FSUploader } from "./SubmissionsClass/FSUploader.js";
import { HashesManager } from "./SubmissionsClass/HashesManager.js";
import { GameSelector } from "./SubmissionsClass/GameSelector.js";
import { MainImageManager } from "./SubmissionsClass/MainImageManager.js";
import { GalleryManager } from "./SubmissionsClass/GalleryManager.js";
import { Credits } from "./SubmissionsClass/Credits.js";
/**
* If there is some server side errors.
* We may need reload some things.
* @type {boolean}
*/
const SERVER_SIDE_ERRORS = document.querySelector('meta[name="submission-has-errors"]')?.content === '1';
/**
* Object map of errors messages
* @type {Object<string,string>}
*/
const ERROR_TABLE = {
isUploading: "A file is uploading. Please wait.",
noFiles: "Please select a file to upload",
uploadError: "One or more files failed to upload.",
notAllFilesDone: "Not all the files have finished uploading yet.",
noModifications: "Please select at least a type of hack.",
noDescription: "Please provide a description.",
noGame: "Please provide a game or create a new one and fill all the required fields.",
noLanguages: "Please select at least a language.",
noAuthors: "Please provide at least an author or create a new one and fill all the required fields.",
noMainImage: "Please select a main image.",
noGalleryImages: "Please select at least a gallery image.",
isSubmitting: "The entry is already during submission."
}
/**
* Current section.
* @returns {string}
* @constructor
*/
const SECTION = () => document.querySelector("meta[name='fs-section']")?.content ?? '';
window.FSUploader = FSUploader;
window.HashesManager = HashesManager;
window.GameSelector = GameSelector;
window.MainImageManager = MainImageManager;
window.GalleryManager = GalleryManager;
window.Credits = Credits;
/**
* Verify if at least one checkbox is checked in this element.
* @param {HTMLElement} element
* @returns {boolean}
*/
function verifyCheckboxes( element ){
if( !parent ) return false;
return Array.from(element.querySelectorAll('input[type="checkbox"]')).some(el => el.checked);
}
/**
* Verify if an EasyMDE field is filled.
*
* @param {string} fieldName
* @returns {boolean}
*/
function verifyMDE( fieldName ){
const textarea = document.querySelector('#field_' + fieldName);
if( textarea && textarea.value.trim().length > 0 ) {
return true;
}
const field = window['mde_' + fieldName] || null;
return field && typeof field.value === 'function' && field.value().trim().length > 0;
}
window.SubmissionVerifications = {
/**
* Verify if we are in an upload.
* @param {FSUploader} Uploader
* @returns {boolean}
*/
step1_DuringFSUpload: function( Uploader ){
return !Uploader.isUploading;
},
/**
* Verify if at least one file is uploaded.
* @param {FSUploader} Uploader
* @returns {boolean}
*/
step2_NoFilesFSUpload: function( Uploader ){
return Uploader.numberOfFiles > 0;
},
/**
* Verify if any files haven't error.
* @param {FSUploader} Uploader
* @returns {boolean}
*/
step3_ErrorsFSUpload: function( Uploader ){
return !Uploader.hasErrors;
},
/**
* Check if all files are uploaded.
* @param {FSUploader} Uploader
* @returns {boolean}
*/
step4_AllFilesUploadedFSUpload: function( Uploader ){
return Uploader.allFilesUploaded;
},
/**
* Verify if at least one checkbox of romhacks modifications is checked.
* @returns {boolean}
*/
step5_RomhacksModificationsCheckboxes: function(){
return verifyCheckboxes( document.querySelector( '#modifications-group' ) );
},
/**
* Verify if the description field has at least one character.
* @returns {boolean}
*/
step6_VerifyDescription: function(){
return verifyMDE('description');
},
/**
* Verify if a game is provided.
* @param element this.$el
* @returns {boolean}
*/
step7_VerifyGame: function( element ){
// Check if we have an already existent selected game.
const GAME_ID_INPUT = document.querySelector('input[name="game_id"]');
if( GAME_ID_INPUT ){
if( GAME_ID_INPUT.value !== '' && Number(GAME_ID_INPUT.value) > 0){
return true;
}
}
// Check if we have a new game.
let gameSelector = element.querySelector('[x-data="GameSelector()"]');
gameSelector = gameSelector ? Alpine.$data(gameSelector) : null;
if( gameSelector !== null ){
if( !gameSelector.name || !gameSelector.name.toString().trim().length )
return false;
if( !gameSelector.platformId || gameSelector.platformId === '' || gameSelector.platformId === 0 )
return false;
if( !gameSelector.genreId || gameSelector.genreId === '' || gameSelector.genreId === 0 )
return false;
return true;
}
return false;
},
/**
* Verify if at least one checkbox of languages is checked.
* @returns {boolean}
*/
step8_LanguagesCheckboxes: function(){
return verifyCheckboxes( document.querySelector( '#languages-group' ) );
},
/**
* Verify if at least one (new) author has been filled.
* @return {boolean}
*/
step9_verifyAuthors: function(){
const authorField = document.querySelectorAll('input[name="authors[]"]');
const newAuthorField = document.querySelectorAll('input[name="new-authors[]"]');
return ( authorField.length > 0 || newAuthorField.length > 0 );
},
/**
* Verify if a main image has been uploaded.
* @param element this.$el
* @return {boolean}
*/
step10_verifyMainImage: function( element ){
let MainImageData = element.querySelector('[x-data="MainImageManager()"]');
MainImageData = MainImageData ? Alpine.$data(MainImageData) : null;
if( ! MainImageData ){
return false;
}
return MainImageData.uploaded;
},
/**
* Verify if at least one image is uploaded in the gallery.
* @param element this.$el
* @return {boolean}
*/
step11_verifyGallery: function( element){
let GalleryData = element.querySelector('[x-data="GalleryManager()"]');
GalleryData = GalleryData ? Alpine.$data(GalleryData) : null;
if( ! GalleryData ){
return false;
}
return GalleryData.number > 0 && GalleryData.allUploaded;
}
}
/**
* Handle entire submission process.
*/
window.Submission = function(){
return {
/**
* If the script is during a try of submission process.
* @type {boolean}
*/
duringSubmissionProcess: false,
/**
* Error checked.
* @type {string|null}
*/
errorKey: null,
/**
* Return error message.
* @return {string}
*/
get errorMessage(){
return ERROR_TABLE[this.errorKey] ?? "Unknown error";
},
init(){
},
/**
* Get current FSUploader if initialized.
*
* @returns {FSUploader|null}
* @constructor
*/
get Uploader(){
const el = this.$el.querySelector('[x-data="FSUploader()"]');
return el ? Alpine.$data(el) : null;
},
/**
* Do each form verifications.
* Update also this.errorKey.
*
* @returns {boolean}
*/
verifyForm(){
console.log( "Step 1" );
if( !SubmissionVerifications.step1_DuringFSUpload( this.Uploader ) ){
this.errorKey = "isUploading";
return false;
}
console.log( "Step 2" );
if( !SubmissionVerifications.step2_NoFilesFSUpload( this.Uploader ) ){
this.errorKey = "noFiles";
return false;
}
console.log( "Step 3" );
if( !SubmissionVerifications.step3_ErrorsFSUpload( this.Uploader ) ){
this.errorKey = "uploadError";
return false;
}
console.log( "Step 4" );
if( !SubmissionVerifications.step4_AllFilesUploadedFSUpload( this.Uploader ) ){
this.errorKey = "notAllFilesDone";
return false;
}
if( SECTION() === "romhacks" ){
console.log( "Step 5" );
if( !SubmissionVerifications.step5_RomhacksModificationsCheckboxes()){
this.errorKey = "noModifications";
return false;
}
}
console.log( "Step 6" );
if( !SubmissionVerifications.step6_VerifyDescription() ){
this.errorKey = "noDescription";
return false;
}
console.log( "Step 7" );
if( !SubmissionVerifications.step7_VerifyGame( this.$el ) ){
this.errorKey = "noGame";
return false;
}
console.log( "Step 8" );
if( !SubmissionVerifications.step8_LanguagesCheckboxes()){
this.errorKey = "noLanguages";
return false;
}
console.log( "Step 9" );
if( !SubmissionVerifications.step9_verifyAuthors()){
this.errorKey = "noAuthors";
return false;
}
console.log( "Step 10" );
if( !SubmissionVerifications.step10_verifyMainImage( this.$el )){
this.errorKey = "noMainImage";
return false;
}
console.log( "Step 11" );
if( !SubmissionVerifications.step11_verifyGallery( this.$el )){
this.errorKey = "noGalleryImages";
return false;
}
return true;
},
/**
* Scroll to the specific error field.
*/
scrollToError(){
const refMap = {
noFiles: 'uploadTarget',
isUploading: 'uploadTarget',
notAllFilesDone: 'uploadTarget',
uploadError: 'uploadTarget',
noModifications: 'modificationsGroup',
noDescription: 'descriptionField',
noGame: 'gameSelector',
noLanguages: 'languagesGroup',
noAuthors: 'authorsSelector',
noMainImage: 'main-image-field',
noGalleryImages: 'gallery-field',
isSubmitting: 'submitButton'
};
const target = this.$refs[refMap[this.errorKey]]
|| this.$el.querySelector('.upload-list')
|| this.$el.querySelector('.form-upload');
if (target) {
target.scrollIntoView({behavior: 'smooth', block: 'center'});
return;
}
},
/**
* If you want to submit the form.
* @param {Event} e
*/
submitForm( e ){
if( this.duringSubmissionProcess )
return; // Don't submit two times.
this.errorKey = null; // Reset.
this.duringSubmissionProcess = true;
if( !this.verifyForm() ){
this.scrollToError();
this.duringSubmissionProcess = false;
return;
}
e.target.submit();
}
}
}

104
resources/js/uploader.js Normal file
View File

@@ -0,0 +1,104 @@
import {createIcons, icons} from "lucide";
export const CHUNK_SIZE = 8192;
export function FSUploader(){
return {
files: [],
rawFiles: [],
section: document.querySelector("meta[name='fs-section']")?.content ?? '',
init( oldFiles ){
this.$watch('files', () =>{
this.$nextTick(() => window.refreshIcons(this.$el) )
})
},
get isUploading(){
return this.files.some(f => !f.done && !f.error);
},
get hasErrors(){
return this.files.some(f => f.error);
},
get allUploaded(){
return this.files.length === 0 || this.files.every(f => f.done);
},
async submitFile(e){
const selected = Array.from(e.target.files);
e.target.value = '';
for( const raw of selected ){
const totalChunks = Math.ceil(raw.size / CHUNK_SIZE );
const index = this.files.length;
this.files.push({
name: raw.name,
progress: 0,
currentChunk: 0,
totalChunks: totalChunks,
done: false,
error: null,
uuid: crypto.randomUUID()
});
this.rawFiles.push(raw);
this.uploadChunks(raw,index);
}
},
async uploadChunks(rawFile, index){
const file = this.files[index];
const url = `/api/fs/upload-chunk/${this.section}`;
const csrf = document.querySelector('meta[name=csrf-token]')?.content ?? '';
for( let i = 0; i < file.totalChunks; i++ ){
if( file.error )
return;
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, rawFile.size);
const chunk = rawFile.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('file_uuid', file.uuid);
formData.append('current_chunk', i);
formData.append('total_chunks', file.totalChunks);
formData.append( 'filename', rawFile.name );
formData.append('_token', csrf );
try {
const response = await fetch(url, { method: 'POST', body: formData });
if(!response.ok)
throw new Error(`${response.status} ${response.statusText}`);
const data = await response.json();
if( data.success !== true || data.uploaded !== true )
throw new Error(`${data.error}`);
file.currentChunk = i + 1;
file.progress = Math.round((( i + 1) / file.totalChunks ) * 100);
if( data.finished === true ){
file.done = true;
}
} catch(err){
file.error = 'Error on chunk ' + ( i + 1 ) + '. ' + err.message;
file.progress = 0;
return;
}
}
},
retry(index){
const rawFile = this.rawFiles[index];
const totalChunks = Math.ceil(rawFile.size / CHUNK_SIZE );
this.files[index] = {
name: rawFile.name,
progress: 0,
currentChunk: 0,
totalChunks: totalChunks,
done: false,
error: null,
uuid: crypto.randomUUID()
};
this.uploadChunks(raw,index);
},
remove(index){
this.files.splice(index, 1);
this.rawFiles.splice(index, 1);
}
}
}