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

26
resources/css/app.css Normal file
View File

@@ -0,0 +1,26 @@
@import './base/variables.css';
@import './base/reset.css';
@import './layout/menu.css';
@import './layout/content.css';
@import './layout/entry.css';
@import './layout/news.css';
@import './components/common.css';
@import './components/grid.css';
@import './components/forms.css';
@import './components/cards.css';
@import './components/modal.css';
@import './components/files.css';
@import './components/easymde.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@@ -0,0 +1,37 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--typography);
background-color: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
overflow: hidden;
}
a {
color: var(--rhpz-orange);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--rhpz-orange-hover);
text-decoration: underline;
}
#app {
display: flex;
width: 100%;
height: 100%;
}
ul {
margin-left: 20px;
margin-bottom: 20px;
list-style-type: square;
}

View File

@@ -0,0 +1,31 @@
:root {
/* RHPZ color */
--rhpz-orange: #ff7300;
--rhpz-orange-hover: #e56700;
/* Background colors */
--bg: #1e1e1e;
--bg2: #252526;
--bg3: #2d2d30;
--bg4: #3e3e42;
/* Text */
--text: #f1f1f1;
--text2: #a1a1aa;
--text3: #111111;
/* Elements */
--border: #3f3f46;
--error: #e57373;
--info: #1976d2;
--success: #81c784;
--success2: #388e3c;
/* Typo */
--typography: 'Segoe UI', 'San Francisco', 'Helvetica Neue', sans-serif;
/* Menu settings */
--menu-size: 260px;
--menu-user-avatar-bg: #555;
}

View File

@@ -0,0 +1,24 @@
/* STAT CARDS */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background-color: var(--bg2);
border: 1px solid var(--border);
padding: 20px;
display: flex;
align-items: center;
gap: 15px;
border-left: 3px solid var(--rhpz-orange);
}
.stat-card i {
color: var(--rhpz-orange);
width: 32px;
height: 32px;
}

View File

@@ -0,0 +1,145 @@
/* BUTTONS */
.btn {
background-color: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 16px;
font-size: 0.9rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.1s;
}
.btn:hover {
background-color: var(--bg4);
border-color: var(--menu-user-avatar-bg);
}
.btn.primary {
background-color: var(--rhpz-orange);
color: var(--text3);
border-color: var(--rhpz-orange);
font-weight: 600;
}
.btn.primary:hover {
background-color: var(--rhpz-orange-hover);
border-color: var(--rhpz-orange-hover);
}
.btn.danger {
background-color: transparent;
color: var(--error);
border-color: var(--error);
}
.btn.danger:hover {
background-color: rgba(229, 115, 115, 0.1);
}
.btn.success {
background-color: transparent;
color: var(--success);
border-color: var(--success);
}
.btn.success:hover {
background-color: rgba(129, 199, 132, 0.1);
}
/* BLOCK */
.block {
background-color: var(--bg2);
border: 1px solid var(--border);
padding: 20px;
margin-bottom: 20px;
}
.block.featured {
border-left: 3px solid var(--rhpz-orange);
}
.block-header {
font-size: 1.2rem;
color: var(--text);
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
}
.block-error {
background-color: var(--error);
border: 1px solid var(--error);
color: var(--text);
padding: 20px;
margin-bottom: 20px;
}
/* BADGES */
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: bold;
border-radius: 2px;
background-color: var(--bg3);
border: 1px solid var(--border);
}
.badge.orange {
background-color: var(--rhpz-orange);
color: var(--text3);
border-color: var(--rhpz-orange);
}
.badge.blue {
background-color: var(--info);
color: var(--text);
border-color: var(--info);
}
.badge.green {
background-color: var(--success2);
color: var(--text);
border-color: var(--success2);
}
/* BREADCRUMB */
.breadcrumb {
margin-bottom: 15px;
}
/* PAGE */
.page-title {
font-size: 1.8rem;
font-weight: 300;
margin-bottom: 20px;
color: var(--text);
}
/* TEXTS */
.whisper {
color: var(--text2);
margin-bottom: 15px;
}
.content-title {
color: var(--text);
margin: 30px 0 15px 0;
border-left: 3px solid var(--rhpz-orange);
padding-left: 10px;
}
.quote {
background-color: var(--bg);
border-left: 4px solid #1976d2;
padding: 15px;
margin-top: 30px;
font-style: italic;
}
/* ANIMATIONS */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s infinite linear;
}

View File

@@ -0,0 +1,80 @@
.EasyMDEContainer {
display: flex;
flex-direction: column;
.editor-toolbar {
background-color: var(--bg3);
border:1px solid var(--border);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
padding: 4px 8px;
opacity: 1;
button {
color: var(--text2);
border: none;
&:hover {
background-color: var(--bg2);
color: var(--text)
}
.active {
background-color: var(--bg2);
color: var(--text);
}
}
i.separator {
border-left: 1px solid var(--border);
margin: 0 4px;
}
}
.CodeMirror {
background-color: var(--bg2);
color: var(--text);
border: 1px solid var(--border) !important;
&:focus-within {
border-color: var(--rhpz-orange);
outline: none;
}
}
.CodeMirror-cursor {
border-left: 2px solid var(--text2);
}
.CodeMirror-selected {
background: color-mix(in srgb, var(--rhpz-orange-hover) 25%, transparent);
}
.cm-header {
color: var(--rhpz-orange-hover);
font-weight: 700;
}
.cm-strong {
color: var(--text);
font-weight: 700;
}
.cm-em {
color: var(--text2);
font-style: italic;
}
&.cm-link, &.cm-url {
color: var(--rhpz-orange);
}
.cm-strikethrough, .cm-comment {
color: var(--text2);
}
.editor-preview, .editor-preview-side {
background: var(--bg2);
color: var(--text);
}
}

View File

@@ -0,0 +1,35 @@
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background-color: var(--bg);
border: 1px solid var(--border);
transition: border-color 0.2s;
&:hover {
border-color: var(--rhpz-orange);
}
.file-info {
display: flex;
flex-direction: column;
.file-name {
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.file-meta {
font-size: 0.8rem;
color: var(--text2);
}
}
}

View File

@@ -0,0 +1,461 @@
.form-group {
margin-bottom: 20px;
}
.form-group.level {
background-color: var(--bg3);
padding: 25px;
border: 1px dashed var(--border);
margin-bottom: 35px;
border-radius: 4px;
}
.form-group-title {
color: var(--text);
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
font-size: 1.15rem;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
}
.form-group label, .form-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text);
font-size: 0.95rem;
}
.form-group label span, .form-label span {
color: var(--text2);
font-weight: normal;
font-size: 0.8rem;
margin-left: 5px;
}
.form-error-text {
margin-top: 10px;
font-size: 0.85rem;
color: var(--error);
display: flex;
align-items: center;
gap: 5px;
}
.form-input, .form-select, .form-textarea, .form-field {
width: 100%;
background-color: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
font-family: var(--typography);
font-size: 0.95rem;
transition: border-color 0.2s;
outline: none;
&:focus {
border-color: var(--rhpz-orange);
}
&:disabled {
background-color: var(--bg3);
color: var(--text);
}
}
.form-textarea {
resize: vertical;
min-height: 120px;
}
.form-checkbox, .form-radio {
accent-color: var(--rhpz-orange);
background-color: var(--bg2);
border: 1px solid var(--border);
}
.game-selector {
position: relative;
width: 100%;
}
.game-selector-level2 {
position: relative;
z-index: 1;
}
.game-selector-dropdown {
position: absolute !important;
width: 100%;
min-width: 100%;
top: calc(100% + 0.35rem);
left: 0;
margin: 0;
padding: 0.35rem 0;
z-index: 9999;
max-height: 260px;
overflow-y: auto;
overflow-x: hidden;
background-color: var(--bg2);
border: 1px solid var(--border);
box-shadow: 0 18px 35px rgba(0, 0, 0, 0.08);
list-style: none;
will-change: transform, opacity;
}
.game-selector-dropdown::-webkit-scrollbar {
width: 8px;
}
.game-selector-dropdown::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 999px;
}
.game-selector-dropdown li {
margin: 0;
}
.dropdown-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: transparent;
border: none;
color: var(--text);
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease;
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: rgba(255, 255, 255, 0.08);
color: var(--text);
outline: none;
}
.dropdown-item[selected],
.dropdown-item.selected,
.dropdown-item.is-selected {
background-color: rgba(255, 115, 0, 0.15);
}
.dropdown-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-empty {
padding: 0.85rem 1rem;
color: var(--text2);
}
.form-upload {
position: relative;
display: flex;
align-items: center;
.disabled {
opacity: 0.5;
pointer-events: none;
}
input[type="file"] {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
}
.form-upload-placeholder {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--bg2);
border: 1px dashed var(--border);
padding: 12px;
color: var(--text);
transition: border-color 0.2s, background-color 0.2s;
}
.form-upload-placeholder.level {
padding: 30px;
text-align: center;
display: block;
background-color: var(--bg);
}
.form-upload:hover .form-upload-placeholder {
border-color: var(--rhpz-orange);
background-color: rgba(255, 115, 0, 0.05);
}
.form-type-of-checkboxes {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1rem;
label {
box-sizing: border-box;
margin: 0;
}
input[type="checkbox"] {
transform: scale(1.5);
margin-right: 1.33rem;
}
}
.form-status-radio {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
label {
box-sizing: border-box;
margin: 0;
}
input[type="radio"] {
transform: scale(1.5);
margin-right: 1.33rem;
}
}
.languages-selector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.language-search {
display: flex;
align-items: center;
gap: 15px;
background-color: var(--bg3);
border: 1px solid var(--border);
padding: 0 10px;
height: 36px;
}
.language-search input {
flex: 1;
height: 100%;
background: transparent;
border: none;
outline: none;
color: var(--text);
}
.language-search [data-lucide] {
color: var(--text2);
flex-shrink: 0;
}
.language-search .btn {
padding: 0;
background: transparent;
border: none;
display: flex;
align-items: center;
flex-shrink: 0;
}
.language-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 260px;
overflow-y: auto;
background: var(--bg3);
border: 1px solid var(--border);
scrollbar-width: thin;
scrollbar-color: var(--rhpz-orange);
padding: 2%;
}
.language-item {
display: flex;
align-items: center;
gap: 15px;
accent-color: var(--rhpz-orange);
}
.main-image-grid {
display: flex;
flex-direction: row;
gap: 1rem;
}
.form-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
height: 100%;
min-height: 120px;
background-color: var(--bg);
border: 1px solid var(--border);
color: var(--text2);
}
.form-image-preview {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-image-preview-wrap {
position: relative;
overflow: hidden;
border: 1px solid var(--border);
aspect-ratio: 16/9;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.form-image-remove {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
background-color: var(--bg2);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text);
transition: background-color 0.15s;
&:hover {
background-color: var(--bg3);
}
}
.form-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.gallery-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.authors-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
button {
padding: 5px;
}
}
.submit {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 15px;
}
.submit-level {
display: flex;
align-items: center;
gap: 15px;
}
.nsfw-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
white-space: nowrap;
}
.upload-list {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 12px;
}
.upload-item {
display: flex;
align-items: center;
gap: 15px;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background-color: var(--bg2);
transition: background-color 0.15s, border-color 0.15s;
& > [data-lucide] {
flex-shrink: 0;
width: 20px;
height: 20px;
}
}
.upload-item-uploading {
border-color: var(--border);
background-color: var(--bg2);
& > [data-lucide] {
color: var(--text2);
}
}
.upload-item-done {
border-color: color-mix(in srgb, var(--rhpz-orange) 40%, transparent);
background-color: color-mix(in srgb, var(--rhpz-orange) 6%, transparent);
& > [data-lucide] {
color: var(--rhpz-orange);
}
}
.upload-item-error {
border-color: color-mix(in srgb, var(--error) 40%, transparent);
background: color-mix(in srgb, var(--error) 6%, transparent);
& > [data-lucide] {
color: var(--error);
}
}
.upload-item-info {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
min-width: 0;
}
.upload-item-name {
color: var(--text);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
height: 6px;
background-color: var(--bg3);
border-radius: 99px;
overflow: hidden;
position: relative;
}
.progress-bar {
height: 100%;
background-color: var(--rhpz-orange);
border-radius: 99px;
transition: width 0.2s ease;
min-width: 4px;
}
.progress-bar-label {
position: absolute;
top: -18px;
right: 0;
font-size: 11px;
color: var(--text2);
white-space: nowrap;
}

View File

@@ -0,0 +1,29 @@
.grid-c2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.grid-c3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
}
@media (max-width: 1100px) {
.grid-c3 { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 768px) {
.grid-c2, .grid-c3 { grid-template-columns: 1fr; }
}
.grid-hashes {
display: grid;
grid-template-columns: repeat(4,0.5fr) 0.25fr;
gap: 20px;
}
.grid-credits {
display: grid;
grid-template-columns: 0.5fr 1fr 0.25fr;
gap: 20px;
}

View File

@@ -0,0 +1,53 @@
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
animation: fadeIn 0.2s ease;
}
.modal-window {
background-color: var(--bg2);
border: 1px solid var(--border);
width: 100%;
max-width: 500px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 15px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg3);
.modal-title {
font-weight: 600;
font-size: 1.1rem;
color: var(--text);
}
.modal-close {
background: none;
border: none;
color: var(--text2);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--text);
}
}
}
.modal-content {
padding: 20px;
}

View File

@@ -0,0 +1,54 @@
#main-wrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#topbar {
height: 60px;
background-color: var(--bg2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
.mobile-toggle {
display: none;
background: none;
border: none;
color: var(--text);
cursor: pointer;
}
.search-bar {
display: flex;
align-items: center;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 2px;
padding: 5px 10px;
width: 300px;
input {
background: none;
border: none;
color: var(--text);
outline: none;
margin-left: 8px;
width: 100%;
}
}
}
#content {
flex-grow: 1;
padding: 30px;
overflow-y: auto;
position: relative;
}

View File

@@ -0,0 +1,130 @@
#entry-container {
background-color: var(--bg2);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.entry-header {
display: flex;
padding: 30px;
border-bottom: 1px solid var(--border);
background: linear-gradient(to right, rgba(255,115,0,0.05), transparent);
gap: 30px;
.entry-cover {
width: 200px;
height: 280px;
background-color: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
position: relative;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.entry-info {
flex-grow: 1;
display: flex;
flex-direction: column;
.entry-title {
font-size: 2.2rem;
font-weight: 600;
margin-bottom: 5px;
color: var(--text);
}
.entry-authors {
color: var(--rhpz-orange);
font-size: 1.1rem;
margin-bottom: 20px;
}
.entry-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 25px;
background-color: var(--bg);
padding: 15px;
border: 1px solid var(--border);
}
.entry-meta-item {
display: flex;
flex-direction: column;
}
.entry-meta-label {
font-size: 0.75rem;
color: var(--text2);
text-transform: uppercase;
}
.entry-actions {
margin-top: auto;
display: flex;
gap: 15px;
}
}
}
.entry-content {
padding: 30px;
.entry-section-title {
font-size: 1.2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
margin-bottom: 20px;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
}
.entry-description {
line-height: 1.6;
color: var(--text);
margin-bottom: 30px;
}
.entry-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.entry-gallery-item {
aspect-ratio: 4/3;
background-color: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: var(--rhpz-orange);
}
}
}
.entry-cover-placeholder {
text-align: center;
color: var( --text2 );
}

View File

@@ -0,0 +1,128 @@
#menu {
width: var(--menu-size);
background-color: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: transform 0.3s ease;
z-index: 100;
.menu-header {
padding: 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
.menu-logo {
width: 32px;
height: 32px;
background-color: var(--rhpz-orange);
color: var(--text3);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-weight: bold;
font-size: 18px;
}
.menu-title {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.5px;
color: #fff; /* TODO Change */
}
}
.menu-navigation {
flex-grow: 1;
padding: 10px 0;
overflow-y: auto;
.menu-group {
margin-bottom: 20px;
.menu-group-title {
padding: 0 20px;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text2);
margin-bottom: 8px;
font-weight: 600;
letter-spacing: 1px;
}
.menu-item {
display: flex;
align-items: center;
padding: 10px 20px;
color: var(--text);
text-decoration: none;
gap: 12px;
border-left: 3px solid transparent;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s;
&:hover {
background-color: var(--bg4);
text-decoration: none;
}
.active {
background-color: var(--bg3);
border-left-color: var(--rhpz-orange);
font-weight: 600;
}
i {
width: 20px;
height: 20px;
color: var(--text2);
}
.active i, &:hover i {
color: var( --rhpz-orange );
}
}
}
}
.menu-user {
padding: 15px 20px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
background-color: var(--bg3);
cursor: pointer;
&:hover {
background-color: var(--bg4);
}
.menu-user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var( --menu-user-avatar-bg );
display: flex;
align-items: center;
justify-content: center;
}
.menu-user-info {
display: flex;
flex-direction: column;
&.username {
font-size: 0.9rem;
font-weight: 600;
}
&.user_role {
font-size: 0.75rem;
color: var(--rhpz-orange);
}
}
}
}

View File

@@ -0,0 +1,48 @@
.news-header {
width: 100%;
height: 250px;
background-color: #333;
background-image: linear-gradient(rgba(0,0,0,0.2), rgba(30,30,30,1));
display: flex;
align-items: flex-end;
padding: 30px;
border: 1px solid var(--border);
border-bottom: none;
position: relative;
.news-header-content {
position: relative;
z-index: 2;
.news-title {
font-size: 2.5rem;
font-weight: 300;
color: var(--text);
margin-bottom: 10px;
}
.news-meta {
color: var(--text2);
display: flex;
gap: 15px;
font-size: 0.9rem;
}
}
}
.news-content {
background-color: var(--bg2);
border: 1px solid var(--border);
padding: 40px;
line-height: 1.7;
color: var(--text);
font-size: 1.05rem;
p {
margin-bottom: 20px;
}
}

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);
}
}
}

View File

@@ -0,0 +1,13 @@
@unless ($breadcrumbs->isEmpty())
<div class="breadcrumb">
@foreach ($breadcrumbs as $breadcrumb)
@if ($breadcrumb->url && !$loop->last)
<a href="{{ $breadcrumb->url }}">{{ $breadcrumb->title }}</a> <span>&rsaquo;</span>
@else
<span>{{ $breadcrumb->title }}</span>
@endif
@endforeach
</div>
@endunless

View File

@@ -0,0 +1,8 @@
<div class="entry-meta-item">
<span class="entry-meta-label">{{ $label }}</span>
@if( $route !== "none" )
<a href="{{ $route }}" class="entry-meta-value">{{ $value }}</a>
@else
<span class="entry-meta-value">{{ $value }}</span>
@endif
</div>

View File

@@ -0,0 +1,3 @@
<h2 class="entry-section-title">
@if( $icon != '' )<i data-lucide="{{ $icon }}"></i>@endif {{ $label }}
</h2>

View File

@@ -0,0 +1,3 @@
<div class="block-error">
<i data-lucide="{{ $errorArray['icon'] ?? '' }}"></i> {{ sprintf( $errorArray['message'], $message ) }}
</div>

View File

@@ -0,0 +1,6 @@
<span class="form-error-text">
@if( $icon )
<i data-lucide="alert-triangle" size="14"></i>
@endif
{{ $message }}
</span>

View File

@@ -0,0 +1,10 @@
<label class="form-label">
{{ $name }}
@if( $required )
<span style="color:red">*</span>
@endif
@if( $helper !== "" )
<span>{{ $helper }}</span>
@endif
</label>
{{ $slot }}

View File

@@ -0,0 +1,7 @@
<h3 class="form-group-title">
@if( $icon !== "" )
<i data-lucide="{{ $icon }}" color="var(--rhpz-orange"></i>
@endif
{{ $label }}
</h3>
{{ $slot }}

View File

@@ -0,0 +1,34 @@
<div x-data="GalleryManager()" x-init="init(@js($oldPaths))">
<x-form-field-title name="Screenshots" helper="At least 1 Screenshot required, Maximum 20." required="{{ $required ? 'true' : 'false' }}" />
<div class="form-group main-image-grid">
<div class="form-upload" style="flex:1;" :class="{ 'disabled': isFull }">
<input type="file" id="gallery-field" accept="image/png, image/jpeg, image/webp" multiple :disabled="isFull" @change="handleSubmitFiles($event)">
<div class="form-upload-placeholder level">
<i data-lucide="file-archive" size="36" style="margin-bottom:15px;color:var(--text2)"></i>
<div style="font-size: 1.1rem;color:var(--text);margin-bottom:5px;">
Click or drag'n drop files here.
</div>
<div style="font-size:0.85rem;color:var(--text2);">
Accepted: PNG, JPG or WebP
</div>
</div>
</div>
<div class="form-gallery form-group level" style="flex:4;">
<template x-for="(image,i) in images" :key="image.serverFilePath">
<div class="gallery-item">
<div class="form-image-preview-wrap">
<img :src="image.preview" :alt="image.name">
<button type="button" class="form-image-remove" @click="handleRemoveFile(i)">
X
</button>
</div>
</div>
</template>
</div>
</div>
<template x-for="(image, i) in images" :key="image.serverFilePath">
<input type="hidden" name="gallery[]" :value="image.serverFilePath">
</template>
</div>

View File

@@ -0,0 +1,29 @@
<?php /** @var \App\Models\Language $language */ ?>
<div class="languages-selector form-group level" x-data="{
search: '',
selected: {{ JS::from( (array) $selected ) }},
toggle(value){
const i = this.selected.indexOf(value);
i === -1 ? this.selected.push(value) : this.selected.splice(i,1);
},
valueSelect(value){
return this.selected.includes(value);
},
get count(){ return this.selected.length; }
}">
<div class="language-search">
<i data-lucide="search"></i>
<input type="text" x-model="search" placeholder="Search language" autocomplete="off">
<button class="btn" type="button" x-show="search !== ''" @click="search = ''" x-cloak>
<i data-lucide="x"></i>
</button>
</div>
<div class="language-list" id="languages-group">
@foreach( $languages as $language )
<label class="language-item" x-show="'{{ strtolower($language->name) }}'.includes(search.toLowerCase())">
<input type="checkbox" name="languages[]" value="{{ $language->id }}" x-model="selected" :value="{{ $language->id }}" {{ in_array($language->id, $selected) ? 'checked' : '' }}> {{ $language->name }}
</label>
@endforeach
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div x-data="MainImageManager()" x-init="init('{{$oldPath}}')">
<x-form-field-title name="Main image" helper="This will show up on the index and on top of the entry. A screenshot or custom cover is prefered else all entries of same game will look the same." required="{{ $required ? 'true' : 'false' }}" />
<div class="form-group main-image-grid">
<div class="form-upload" style="flex:4;">
<input type="file" id="main-image-field" accept="image/png, image/jpeg, image/webp" @change="handleSubmitFile($event)">
<div class="form-upload-placeholder level">
<i data-lucide="file-archive" size="36" style="margin-bottom:15px;color:var(--text2)"></i>
<div style="font-size: 1.1rem;color:var(--text);margin-bottom:5px;">
Click or drag'n drop files here.
</div>
<div style="font-size:0.85rem;color:var(--text2);">
Accepted: PNG, JPG or WebP
</div>
</div>
<input type="hidden" name="main-image" x-model="serverFilePath">
</div>
<div class="form-image" style="flex:1">
<div class="form-image-placeholder" x-show="!preview">
<i data-lucide="image" size="48"></i>
</div>
<div class="form-image-preview" x-show="preview" x-cloak>
<div class="form-image-preview-wrap">
<img :src="preview" alt="Main Image">
<button type="button" class="form-image-remove" @click="handleRemoveFile()">
<i data-lucide="x"></i>
</button>
</div>
<div class="form-image-info">
<span x-text="name"></span>
</div>
<span class="form-error-text" x-show="error !== null" x-text="error"></span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<div x-data
x-init="
window.mde_{{ $name }} = new EasyMDE({
element: $el.querySelector('#field_{{ $name }}'),
minHeight: '{{ $minHeight }}',
toolbar: {{ Js::from( $toolbar ) }},
autosave: {
enabled: true,
uniqueId: '{{ $name }}',
delay: 1000,
},
})
"
>
<textarea class="form-textarea" id="field_{{ $name }}" name="{{ $name }}">{{ $value }}</textarea>
</div>

View File

@@ -0,0 +1,40 @@
<nav id="menu">
<div class="menu-header">
<div class="menu-logo">
RP
</div>
<div class="menu-title">
Romhack Plaza
</div>
</div>
<div class="menu-navigation">
<div class="menu-group">
<div class="menu-group-title">Website</div>
<a href="{{ route('home') }}"
@class(['menu-item', 'active' => request()->routeIs('home')]) >
<i data-lucide="home"></i><span>Home</span>
</a>
</div>
</div>
<div class="menu-user">
<div class="menu-user-avatar">
<x-xen-foro-avatar />
</div>
<div class="menu-user-info">
<span class="username">
{{ \Auth::user()?->username ?? "Guest" }}
</span>
<span class="user_role">
Lorem
</span>
</div>
</div>
</nav>

View File

@@ -0,0 +1,31 @@
<div x-data='Credits()' x-init="init(@js($oldStaffCredits))">
<x-form-field-title name="Staff/Credits" />
<template x-if="credits.length > 0">
<div class="form-group grid-credits">
<div><x-form-field-title name="Name" /></div>
<div><x-form-field-title name="Description" /></div>
<div><x-form-field-title name="Actions" /></div>
<template x-for="(credit,i) in credits" :key="i">
<div style="display:contents">
<div>
<input class="form-input" type="text" x-model="credit.name" autocomplete="off">
</div>
<div>
<input class="form-input" type="text" x-model="credit.description" autocomplete="off">
</div>
<div>
<button type="button" class="btn" @click="removeCredits(i)">
Remove
</button>
</div>
</div>
</template>
</div>
</template>
<input type="hidden" name="staff_credits" x-model="JSON.stringify(credits)">
<div style="display:flex;justify-content:flex-end;margin-top:10px;">
<button type="button" class="btn primary" @click="addEmptyCredits()">
Add a credit
</button>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<div class="submit-level" x-data="{
nsfw: null,
state: '{{ old('submit-state', $defaultState) }}',
init(){
this.$watch('nsfw', (val) => {
if( val && this.state === 'published' ) {
this.state = 'draft';
}
});
}
}" x-init="init()">
<div>
@if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) )
<label class="nsfw-label"><input id="nsfw-checkbox" type="checkbox" name="nsfw-entry" x-model="nsfw" style="transform: scale(1.5)"> NSFW</label>
@endif
</div>
<select class="form-select" name="submit-state" x-model="state">
@foreach( $states as $k => $v )
@if( $k == 'published' )
<template x-if="!nsfw">
<option value="{{ $k }}" {{ $defaultState == $k ? 'selected' : '' }}>{{ $v }}</option>
</template>
@else
<option value="{{ $k }}" {{ $defaultState == $k ? 'selected' : '' }}>{{ $v }}</option>
@endif
@endforeach
</select>
</div>

View File

@@ -0,0 +1,3 @@
<div class="block-success">
<i data-lucide="{{ $successArray['icon'] ?? '' }}"></i> {{ sprintf( $successArray['message'], $message ) }}
</div>

View File

@@ -0,0 +1,16 @@
<header id="topbar">
<button class="mobile-toggle">
<i data-lucide="menu"></i>
</button>
<div class="search-bar">
<i data-lucide="search" size="18" color="var(--text2)"></i>
<input type="text">Search</input>
</div>
<div class="topbar-actions">
<button class="btn">
<i data-lucide="bell" size="18"></i>
</button>
</div>
</header>

View File

@@ -0,0 +1,16 @@
@if(!$user)
<i data-lucide="user"></i>
@else
@if($user->getAvatarUrl())
<img src="{{ $user->getAvatarUrl('m') }}" alt="avatar">
@else
<div style="
background: {{\App\Helpers\XenForoHelpers::getAvatarColor($user) }};
width: 40px; height: 40px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color:var(--text); font-weight: bold;
">
{{ \App\Helpers\XenForoHelpers::getAvatarLetter($user) }}
</div>
@endif
@endif

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>RomHack Plaza</title>
</head>
<body>
<h1>Bienvenue sur RomHack Plaza</h1>
<p>Le catalogue est en construction.</p>
</body>
</html>

View File

@@ -0,0 +1,97 @@
@extends('layouts.app')
@section('page-title', $entry->title . " - " . config('app.name') )
@section('content')
{{ \Diglactic\Breadcrumbs\Breadcrumbs::render() }}
<article id="entry-container">
<div class="entry-header">
<div class="entry-cover">
@if( $entry->main_image )
<img src="{{ Storage::url($entry->main_image) }}">
@else
<div class="entry-cover-placeholder">
<i data-lucide="image" size="48"></i>
</div>
@endif
</div>
<div class="entry-info">
<h1 class="entry-title">
{{ $entry->title }}
</h1>
<div class="entry-authors">
@forelse( $entry->authors as $author)
@if($loop->first)By @endif
{{ $author->name }}
@if( !$loop->last ), @endif
@empty
No authors
@endforelse
</div>
<div class="entry-meta-grid">
@if( $entry->game )
<x-entry-meta-item label="Game Name" value="{{ $entry->game->name }}" />
@endif
@if( $entry->getRealPlatform() )
<x-entry-meta-item label="Platform" value="{{ ($entry->getRealPlatform())->name }}" />
@endif
@if( $entry->game && $entry->game->genre )
<x-entry-meta-item label="Genre" value="{{ $entry->game->genre->name }}" />
@endif
@if( $entry->languages->isNotEmpty() )
<x-entry-meta-item label="Language" value="{{ $entry->languages->pluck('name')->implode(', ') }}" route="none" />
@endif
@if( $entry->status_id )
<x-entry-meta-item label="Status" value="{{ $entry->status->name }}" />
@endif
@if( $entry->version )
<x-entry-meta-item label="Version" value="{{ $entry->version }}" route="none" />
@endif
@if( $entry->release_date )
<x-entry-meta-item label="Release Date" value="{{ $entry->release_date }}" />
@endif
@if( $entry->modifications->isNotEmpty() )
<x-entry-meta-item label="Type of hack" value="{{ $entry->modifications->pluck('name')->implode(', ') }}" route="none" />
@endif
</div>
<div class="hack-actions">
<button class="btn primary">
<i data-lucide="download"></i> Download
</button>
<button class="btn">
<i data-lucide="message-square"></i> Comments
</button>
</div>
</div>
</div>
<div class="entry-content">
@if( $entry->description )
<x-entry-section-title label="Description" icon="file-text" />
<div class="entry-description">
{{ $entry->description }}
</div>
@endif
@if( $entry->hashes->isNotEmpty() )
<x-entry-section-title label="Hashes" icon="table-properties" />
<div class="entry-description">
@foreach( $entry->hashes->all() as $hash )
Filename: {{ $hash->filename }}<br>
CRC32: {{ $hash->hash_crc32 }}<br>
SHA-1: {{ $hash->hash_sha1 }}<br>
Verified: {{ $hash->verified }}<br>
@endforeach
</div>
@endif
@if( $entry->staff_credits )
<h2 class="entry-section-title">
<i data-lucide="users-round"></i> Staff credits
</h2>
<div class="entry-description">
{{ $entry->staff_credits }}
</div>
@endif
</div>
</article>
@endsection

View File

@@ -0,0 +1,10 @@
@extends('layouts.app')
@section('page-title', "Home - " . config('app.name') )
@section('content')
<div class="block">
Ceci est un block !
</div>
<x-error-block error-type="page-not-allowed" />
@endsection

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
@stack('styles')
<title>@yield('page-title', 'Romhack Plaza')</title>
</head>
<body>
<div id="app">
@include( 'components.menu' )
<main id="main-wrapper">
@include('components.topbar')
@if(session('success'))
<x-success-block success-type="custom" :message="session('success')" />
@endif
@if(session('error'))
<x-error-block error-type="custom" :message="session('error')" />
@endif
<div id="content">
@yield('content')
</div>
</main>
</div>
@livewireScripts
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,60 @@
<div>
<div class="form-group grid-c2">
<div>
@if( $newAuthor === false)
<div class="game-selector">
<div class="game-selector-level2">
<x-form-field-title name="Authors" helper="Person or Group who created this, if it's you add/select yourself." required="true" />
<input id="author-search" class="form-input" type="text" wire:model.live.debounce="search" placeholder="Search a game..." autocomplete="off"
@focus="$wire.dropdown = $wire.search.length >= 2" @click.outside="$wire.dropdown = false" >
</div>
@if( $dropdown )
<ul class="game-selector-dropdown">
@forelse($authors as $author)
<li>
<button type="button" wire:click="selectAuthor({{ $author->id }}, '{{ addslashes($author->name) }}')"
class="dropdown-item">
<span class="dropdown-item-name">{{ $author->name }}</span>
</button>
</li>
@empty
<li class="dropdown-empty">No author found</li>
@endforelse
</ul>
@endif
</div>
@else
<div class="new-author">
<x-form-field-title name="New author" required="true" />
<input class="form-input" wire:model="newAuthorName" type="text" autocomplete="off" value="" required>
</div>
@endif
<div style="display:flex;align-items: flex-end;justify-content: right;gap:15px;margin-top:20px;">
@if($newAuthor)
<button type="button" class="btn primary" wire:click="addNewAuthor" :disabled="$wire.newAuthorName.trim() === ''">
Add
</button>
@endif
<button type="button" class="btn {{ $newAuthor ? '' : 'primary' }}" wire:click="switchNewAuthor">{{ $newAuthor ? "Cancel" : "Add an author" }}</button>
</div>
</div>
<div>
<x-form-field-title name="Selected authors" />
<div class="form-group level authors-list">
@foreach($selectedAuthors as $i => $author)
<div class="author-item">
<span>{{ $author['name'] }}</span>
<button type="button" class="btn author-item-remove" wire:click="removeAuthor({{ $i }})">
X
</button>
</div>
@if( $author['id'] )
<input type="hidden" name="authors[]" value="{{ $author['id'] }}">
@else
<input type="hidden" name="new-authors[]" value="{{ $author['name'] }}">
@endif
@endforeach
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<div x-data='GameSelector()' x-init="init({
name: @json(old('new-game-title') ?: null),
platformId: @json(old('new-game-platform') ? (string) old('new-game-platform') : null),
genreId: @json(old('new-game-genre') ? (string) old('new-game-genre') : null)
})">
{{--
Prefill if server-side error.
--}}
<div class="form-group grid-c3">
@if( !$newGame && !$hasOldNewGame )
{{-- Search game mode --}}
<div class="game-selector">
<div class="game-selector-level2">
<x-form-field-title name="Game" required="true" />
<input class="form-input" type="text" wire:model.live.debounce="search" placeholder="Search a game..." autocomplete="off"
@focus="$wire.dropdown = $wire.search.length >= {{ $required_chars }}" @click.outside="$wire.dropdown = false" >
<input type="hidden" name="game_id" value="{{ $gameId ?? '' }}" />
</div>
@if( $dropdown )
{{-- List games --}}
<ul class="game-selector-dropdown">
@forelse($games as $game)
<li>
<button type="button" wire:click="selectGame({{ $game->id }}, '{{ addslashes($game->name) }}')"
class="dropdown-item" {{ $gameId === $game->id ? 'selected' : '' }} >
<span class="dropdown-item-name">{{ $game->name }}</span>
@if($game->platform)
<span class="badge">{{ $game->platform->short_name }}</span>
@endif
@if($game->genre)
<span class="badge">{{ $game->genre->name }}</span>
@endif
</button>
</li>
@empty
<li class="dropdown-empty">No games found</li>
@endforelse
</ul>
@endif
</div>
<div class="platform-prefilled">
<x-form-field-title name="Platform" helper="Prefilled" />
<input class="form-input" disabled="disabled" type="text" autocomplete="off" value="{{ $platformName }}">
</div>
<div class="genre-prefilled">
<x-form-field-title name="Genre" helper="Prefilled" />
<input class="form-input" disabled="disabled" type="text" autocomplete="off" value="{{ $genreName }}" >
</div>
@else {{-- New game --}}
<div class="new-game-title">
<x-form-field-title name="Game" required="true" />
<input class="form-input" name="new-game-title" type="text" autocomplete="off" x-model="name" value="{{ old('new-game-title', '') }}" required>
</div>
<div class="new-game-platform">
<x-form-field-title name="Platform" required="true" />
<select class="form-select" name="new-game-platform" x-model="platformId" required>
<option value="" disabled>---</option>
@foreach( $platforms as $platform )
<option value="{{ (string) $platform->id }}">{{ $platform->name }}</option>
@endforeach
</select>
</div>
<div class="new-game-genre">
<x-form-field-title name="Genre" required="true" />
<select class="form-select" name="new-game-genre" x-model="genreId" required>
<option value="" disabled>---</option>
@foreach( $genres as $genre )
<option value="{{ (string) $genre->id }}">{{ $genre->name }}</option>
@endforeach
</select>
</div>
@endif
</div>
<div style="display:flex;align-items: flex-end;justify-content: right;gap:15px;">
@if($gameId)
<button type="button" class="btn" wire:click="clearGame">
Remove
</button>
@endif
<button type="button" class="btn primary" wire:click="switchNewGame">{{ $newGame ? "Cancel" : "Add a game" }}</button>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<div x-data="HashesManager($wire)" x-ref="hashRoot">
<x-form-field-title name="Hashes" required="true" />
@if( count( $hashes ) > 0)
<div class="form-group grid-hashes">
<div class="hash-filename">
<x-form-field-title name="Filename" />
</div>
<div class="hash-crc32">
<x-form-field-title name="CRC32" />
</div>
<div class="hash-sha1">
<x-form-field-title name="SHA-1" />
</div>
<div class="hash-verified">
<x-form-field-title name="Verified" />
</div>
<div class="hash-remove">
<x-form-field-title name="Actions" />
</div>
@foreach( $hashes as $i => $hash )
<div class="hash-filename">
<input class="form-input" type="text" autocomplete="off" value="{{ $hash['filename'] }}" disabled>
</div>
<div class="hash-crc32">
<input class="form-input" type="text" autocomplete="off" value="{{ $hash['hash_crc32'] }}" disabled>
</div>
<div class="hash-sha1">
<input class="form-input" type="text" autocomplete="off" value="{{ $hash['hash_sha1'] }}" disabled>
</div>
<div class="hash-verified">
<input class="form-input" type="text" autocomplete="off" value="{{ $hash['verified'] }}" disabled>
</div>
<div class="hash-remove">
<button type="button" class="btn" wire:click="removeHash({{ $i }})">
Remove
</button>
</div>
<input type="hidden" name="hashes[{{ $i }}][filename]" value="{{ $hash['filename'] }}">
<input type="hidden" name="hashes[{{ $i }}][hash_crc32]" value="{{ $hash['hash_crc32'] }}">
<input type="hidden" name="hashes[{{ $i }}][hash_sha1]" value="{{ $hash['hash_sha1'] }}">
<input type="hidden" name="hashes[{{ $i }}][verified]" value="{{ $hash['verified'] }}">
@endforeach
</div>
@endif
<div style="display:flex;align-items: flex-end;justify-content: right;gap:15px;">
<span x-show="isCalculating" x-cloak>
<i data-lucide="loader-2" class="spin"></i>
Please wait...
</span>
<span x-show="error" x-text="error" class="form-error-text" x-cloak></span>
<button type="button" class="btn primary" :disabled="isCalculating" @click="handleSubmitFile()">Add Hashes</button>
</div>
</div>

View File

@@ -0,0 +1,7 @@
@extends('layouts.app')
@section('page-title', "Forbidden - " . config('app.name') )
@section('content')
<x-error-block error-type="page-not-allowed" message="{{ $permission }}" />
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.app')
@section('page-title', "Submit - " . config('app.name') )
@section('content')
{{ \Diglactic\Breadcrumbs\Breadcrumbs::render() }}
<div class="page-title">{{ $words['page_title'] }}</div>
@include('submissions.form')
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.app')
@section('page-title', "Edit $entry->title - " . config('app.name') )
@section('content')
{{ \Diglactic\Breadcrumbs\Breadcrumbs::render() }}
<div class="page-title">{{ $words['page_title'] }}</div>
@include('submissions.form')
@endsection

View File

@@ -0,0 +1,172 @@
<?php
/** @var \App\Models\Modification $modif */
/** @var \App\Models\Status $status */
?>
{{-- Pushed in header. --}}
@push('styles')
<meta name="fs-section" content="{{ $section }}">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="submission-has-errors" content="{{ $errors->any() ? '1' : '0' }}">
@endpush
@push('scripts')
@vite('resources/js/submissions.js')
@endpush
{{-- Server side errors summary --}}
@if($errors->any())
@foreach( $errors->all() as $error )
<x-form-error-text message="{{ $error }}" />
@endforeach
@endif
<div class="block">
<form action="{{ $isEdit ? route('submit.update', [ $section, $entry->id ] ) : route('submit.store', $section ) }}"
method="POST" x-data="Submission()" x-init="init()" @submit.prevent="submitForm($event)">
@include('submissions.fs-upload')
<!-- ABOUT THE ENTRY -->
<x-form-group-title label="{{ $words['about_the'] }}" icon="puzzle" />
@if( section_must_not_be( 'translations', $section ) )
<div class="form-group">
<x-form-field-title name="{{ $words['entry_title'] }}" required="true" />
<input class="form-input" type="text" name="entry_title" value="{{ old('entry_title', $entry->title ?? '' ) }}" required>
@error('entry_title')
<x-form-error-text message="{{ $message }}" />
@enderror
</div>
@else
<div class="form-group">
<x-form-field-title name="{{ $words['entry_title'] }}" helper="{{ $words['entry_title_helper'] }}" />
<input class="form-input" type="text" name="entry_title" value="{{ old('entry_title', $entry->title, '' ) }}">
@error('entry_title')
<x-form-error-text message="{{ $message }}" />
@enderror
</div>
@endif
@if( section_must_be( 'romhacks', $section ) )
<div class="form-group">
<x-form-field-title name="{{ $words['type_of_hack'] }}" required="true" />
<div class="form-type-of-checkboxes form-group level" id="modifications-group" x-ref="modificationsGroup">
@foreach( $modifications as $modif )
<label><input class="form-checkbox" type="checkbox" name="modifications[]" value="{{ $modif->id }}" {{ in_array($modif->id, $oldModifications) ? 'checked' : '' }}>{{ $modif->name }}</label>
@endforeach
</div>
<div class="form-error-text" x-show="errorKey === 'noModifications'" x-text="errorMessage"></div>
</div>
@endif
@if( section_must_be( ['romhacks', 'translations'], $section ) )
<div class="form-group grid-c3">
<div>
<x-form-field-title name="{{ $words['version'] }}" required="true" />
<input class="form-input" type="text" name="version" value="{{ old( 'version', $entry->version ?? '' ) }}" required>
</div>
<div>
<x-form-field-title name="{{ $words['release_date'] }}" helper="{{ $words['release_date_helper'] }}" required="true" />
{{-- TODO: Add max to the date --}}
<input type="date" class="form-input" name="release-date" value="{{ old('release-date') ?? $entry->release_date?->format('Y-m-d') ?? '' }}" required>
</div>
<div>
<x-form-field-title name="{{ $words['status'] }}" required="true" />
<div class="form-status-radio form-group level">
@foreach( $statuses as $status )
<label><input class="form-radio" type="radio" name="status" value="{{ $status->id }}" {{ old('status', $entry->status_id ) == $status->id ? 'checked' : '' }} required>{{ $status->name }}</label>
@endforeach
</div>
</div>
</div>
@endif
@if( section_must_be( 'translations', $section ) )
<x-form-field-title name="Languages" required="true" />
<x-languages-selector :selected="$oldLanguages" />
@endif
<div class="form-group" x-ref="descriptionField">
<x-form-field-title name="{{ $words['description'] }}" required="true" />
<x-markdown-textarea name="description" value="{{ old('description', $entry->description ?? '') }}" />
<div class="form-error-text" x-show="errorKey === 'noDescription'" x-text="errorMessage"></div>
@error('description')
<x-form-error-text message="{{ $message }}" />
@enderror
</div>
<x-form-group-title label="{{ $words['about_game'] }}" icon="gamepad-2" />
<div x-ref="gameSelector">
<livewire:game-selector
:game-id="old('game_id', $entry->game_id ?? null )"
:new-game-title="old('new-game-title')"
:new-game-platform="old('new-game-platform')"
:new-game-genre="old('new-game-genre')"
/>
</div>
<div class="form-error-text" x-show="errorKey === 'noGame'" x-text="errorMessage"></div>
@error('game_id')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('new-game-title')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('new-game-platform')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('new-game-genre')
<x-form-error-text message="{{ $message }}" />
@enderror
<livewire:hashes-upload :old-hashes="old('hashes', $entry->hashes->toArray(), [])" />
@if( section_must_not_be( 'translations', $section ) )
<x-form-field-title name="Languages" required="true" />
<x-languages-selector :selected="$oldLanguages" />
@error('languages')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('languages.*')
<x-form-error-text message="{{ $message }}" />
@enderror
@endif
<x-form-group-title label="{{ $words['attachments'] }}" icon="paperclip" />
<x-main-image-field :old-path="old('main-image', $entry->main_image ?? '')" />
<x-gallery-field :old-paths="old('gallery', $entry->gallery->pluck('image')->toArray() ?? [] )"/>
@error('gallery')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('gallery.*')
<x-form-error-text message="{{ $message }}" />
@enderror
<x-form-group-title label="{{ $words['authors'] }}" icon="users" />
<livewire:authors-selector :old-authors="old('authors', $entry->authors->map(fn($a) => ['id' => $a->id, 'name' => $a->name ])->toArray() ?? [])" :old-new-authors="old('new-authors', [])" />
@error('authors')
<x-form-error-text message="{{ $message }}" />
@enderror
@error('new-authors')
<x-form-error-text message="{{ $message }}" />
@enderror
<x-staff-credits-field :old-staff-credits="old('staff_credits', $entry->staff_credits ?? null)" />
<x-form-group-title label="{{ $words['related_links'] }}" icon="link" />
<div class="form-group grid-c2">
<div>
<x-form-field-title name="{{ $words['release_site'] }}" helper="{{ $words['release_site_helper'] }}" required="" />
<input class="form-input" type="url" name="release_site" value="{{ old( 'release_site', $entry->relevant_link ?? '' ) }}">
</div>
<div>
<x-form-field-title name="{{ $words['youtube_video'] }}" required="" />
<input class="form-input" type="url" name="youtube_video" value="{{ old( 'youtube_video', $entry->youtube_link ?? '' ) }}">
</div>
</div>
@csrf
<div class="submit">
<x-submit-entry-status :section="$section" />
<button id="submit-button" type="submit" class="btn primary" style="padding:1%;">Submit</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,69 @@
{{-- File Server uploader import --}}
<div class="form-group level" x-data="FSUploader()" x-init="init(@js($oldFilesArray))">
<x-form-group-title label="Download Files" icon="upload-cloud" />
<div class="form-group">
<x-form-field-title name="Files" helper="test" required="true" />
<div class="form-upload" x-ref="uploadTarget" :class="{ 'disabled': isUploading }">
<input type="file" multiple :disabled="isUploading" @change="handleSubmitFile($event)">
<div class="form-upload-placeholder level">
<i data-lucide="file-archive" size="36" style="margin-bottom:15px;color:var(--text2)"></i>
<div style="font-size: 1.1rem;color:var(--text);margin-bottom:5px;">
Click or drag'n drop files here.
</div>
</div>
</div>
<x-form-error-text message="Don't submit ROMs" />
{{-- Client-side Errors --}}
<div class="form-error-text" x-show="errorKey === 'noFiles' || errorKey === 'uploadError' || errorKey === 'notAllFilesDone'" x-text="errorMessage"></div>
{{-- Server-side Errors --}}
@error('file_ids')
<x-form-error-text message="{{ $message }}" />
@enderror
</div>
{{--
File listing. Used for editions or server-side errors.
--}}
<div class="upload-list" x-show="numberOfFiles > 0" x-cloak>
<template x-for="(file,i) in files" :key="i">
<div class="upload-item" :class="
{
'upload-item-uploading': !file.done && !file.error,
'upload-item-done': file.done,
'upload-item-error': file.error
}">
<template x-if="!file.done && !file.error">
<i data-lucide="loader-2" class="spin"></i>
</template>
<template x-if="file.done">
<i data-lucide="check-circle"></i>
</template>
<template x-if="file.error">
<i data-lucide="alert-circle"></i>
</template>
<div class="upload-item-info">
<span class="upload-item-name" x-text="file.name"></span>
<div class="progress" x-show="!file.done && !file.error">
<div class="progress-bar" :style="{width: file.progressValue + '%' }">
<span class="progress-bar-label" x-text="file.progressValue + '% - chunk' + file.currentChunk + ' / ' + file.totalChunks"></span>
</div>
</div>
<span class="upload-item-error" x-show="file.error" x-text="file.error"></span>
</div>
<div class="upload-item-actions">
<button type="button" class="btn" x-show="file.error" @click="handleRetryFile(i)">
<i data-lucide="refresh-cw"></i>
</button>
<button type="button" class="btn" x-show="file.done || file.error" @click="handleRemoveFile(i)">
<i data-lucide="x"></i>
</button>
</div>
<input type="hidden" name="files_uuid[]" :value="file.uuid" x-show="file.done">
</div>
</template>
</div>

File diff suppressed because one or more lines are too long