Merge pull request #142 from ragestudio/dev

This commit is contained in:
srgooglo 2025-05-15 15:06:27 +02:00 committed by GitHub
commit 6e6f2c5d86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 379 additions and 679 deletions

@ -1 +1 @@
Subproject commit 94c8d7383e84a2de4b193d27adfcb1baa4163f68 Subproject commit fe5cb1a9a275444abd7307bbd8a4e7c68221369b

View File

@ -43,6 +43,7 @@
"background-color-accent": "#f0f2f5", "background-color-accent": "#f0f2f5",
"background-color-contrast": "#4b4b4b", "background-color-contrast": "#4b4b4b",
"border-color": "rgba(75, 75, 75, 0.2)", "border-color": "rgba(75, 75, 75, 0.2)",
"border-color-solid": "#b3b3b3",
"sidebar-background-color": "var(--background-color-accent)", "sidebar-background-color": "var(--background-color-accent)",
"sidedrawer-background-color": "var(--background-color-accent)" "sidedrawer-background-color": "var(--background-color-accent)"
}, },
@ -66,6 +67,7 @@
"background-color-contrast": "#ffffff", "background-color-contrast": "#ffffff",
"background_disabled": "#0A0A0A", "background_disabled": "#0A0A0A",
"border-color": "rgba(170, 170, 170, 0.2)", "border-color": "rgba(170, 170, 170, 0.2)",
"border-color-solid": "#4c4c4c",
"header-text-color": "#d2d2d2", "header-text-color": "#d2d2d2",
"button-background-color": "var(--colorPrimary)", "button-background-color": "var(--colorPrimary)",
"button-text-color": "var(--background-color-contrast)", "button-text-color": "var(--background-color-contrast)",

View File

@ -0,0 +1,17 @@
import js from "@eslint/js"
import globals from "globals"
import pluginReact from "eslint-plugin-react"
import { defineConfig } from "eslint/config"
export default defineConfig([
{
files: ["**/*.{js,mjs,cjs,jsx}"],
plugins: { js },
extends: ["js/recommended"],
},
{
files: ["**/*.{js,mjs,cjs,jsx}"],
languageOptions: { globals: globals.browser },
},
pluginReact.configs.flat.recommended,
])

View File

@ -1,6 +1,6 @@
{ {
"name": "@comty/app", "name": "@comty/app",
"version": "1.41.0@alpha", "version": "1.42.0@alpha",
"license": "ComtyLicense", "license": "ComtyLicense",
"main": "electron/main", "main": "electron/main",
"type": "module", "type": "module",
@ -11,7 +11,8 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"release": "node ./scripts/release.js", "release": "node ./scripts/release.js",
"postinstall": "./scripts/postinstall.sh" "postinstall": "./scripts/postinstall.sh",
"eslint": "eslint"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.4.0", "@ant-design/icons": "^5.4.0",
@ -33,7 +34,7 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0", "bear-react-carousel": "^4.0.10-alpha.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"comty.js": "^0.65.5", "comty.js": "^0.66.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
@ -79,9 +80,13 @@
"vite": "^6.2.6" "vite": "^6.2.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.26.0",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"7zip-min": "1.4.3", "7zip-min": "1.4.3",
"dotenv": "16.0.3", "dotenv": "16.0.3",
"form-data": "^4.0.0" "eslint": "^9.26.0",
"eslint-plugin-react": "^7.37.5",
"form-data": "^4.0.0",
"globals": "^16.1.0"
} }
} }

View File

@ -1,237 +0,0 @@
import { EventBus } from "@ragestudio/vessel"
export default class ChunkedUpload {
constructor(params) {
const {
endpoint,
file,
headers = {},
splitChunkSize = 1024 * 1024 * 10,
maxRetries = 3,
delayBeforeRetry = 5,
} = params
if (!endpoint) {
throw new Error("Missing endpoint")
}
if ((!file) instanceof File) {
throw new Error("Invalid or missing file")
}
if (typeof headers !== "object") {
throw new Error("Invalid headers")
}
if (splitChunkSize <= 0) {
throw new Error("Invalid splitChunkSize")
}
this.chunkCount = 0
this.retriesCount = 0
this.splitChunkSize = splitChunkSize
this.totalChunks = Math.ceil(file.size / splitChunkSize)
this.maxRetries = maxRetries
this.delayBeforeRetry = delayBeforeRetry
this.offline = this.paused = false
this.endpoint = endpoint
this.file = file
this.headers = {
...headers,
"uploader-original-name": encodeURIComponent(file.name),
"uploader-file-id": this.getFileUID(file),
"uploader-chunks-total": this.totalChunks,
"chunk-size": splitChunkSize,
"cache-control": "no-cache",
connection: "keep-alive",
}
this.setupListeners()
this.nextSend()
console.debug("[Uploader] Created", {
splitChunkSize: splitChunkSize,
totalChunks: this.totalChunks,
totalSize: file.size,
fileName: file.name,
fileType: file.type,
})
}
_reader = new FileReader()
events = new EventBus()
setupListeners() {
window.addEventListener(
"online",
() =>
!this.offline &&
((this.offline = false),
this.events.emit("online"),
this.nextSend()),
)
window.addEventListener(
"offline",
() => ((this.offline = true), this.events.emit("offline")),
)
}
getFileUID(file) {
return (
Math.floor(Math.random() * 100000000) +
Date.now() +
file.size +
"_tmp"
)
}
loadChunk() {
return new Promise((resolve) => {
const start = this.chunkCount * this.splitChunkSize
const end = Math.min(start + this.splitChunkSize, this.file.size)
this._reader.onload = () => {
resolve(
new Blob([this._reader.result], {
type: "application/octet-stream",
}),
)
}
this._reader.readAsArrayBuffer(this.file.slice(start, end))
})
}
async sendChunk() {
console.log(`[UPLOADER] Sending chunk ${this.chunkCount}`, {
currentChunk: this.chunkCount,
totalChunks: this.totalChunks,
chunk: this.chunk,
})
try {
const res = await fetch(this.endpoint, {
method: "POST",
headers: {
...this.headers,
"uploader-chunk-number": this.chunkCount,
},
body: this.chunk,
})
return res
} catch (error) {
this.manageRetries()
}
}
manageRetries() {
if (++this.retriesCount < this.maxRetries) {
setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000)
this.events.emit("fileRetry", {
message: `Retrying chunk ${this.chunkCount}`,
chunk: this.chunkCount,
retriesLeft: this.retries - this.retriesCount,
})
} else {
this.events.emit("error", {
message: `No more retries for chunk ${this.chunkCount}`,
})
}
}
async nextSend() {
if (this.paused || this.offline) {
return null
}
this.chunk = await this.loadChunk()
try {
const res = await this.sendChunk()
if (![200, 201, 204].includes(res.status)) {
// failed!!
return this.manageRetries()
}
const data = await res.json()
console.log(`[UPLOADER] Chunk ${this.chunkCount} sent`)
this.chunkCount = this.chunkCount + 1
if (this.chunkCount < this.totalChunks) {
this.nextSend()
}
// check if is the last chunk, if so, handle sse events
if (this.chunkCount === this.totalChunks) {
if (data.sseChannelId || data.sseUrl) {
this.waitOnSSE(data)
} else {
this.events.emit("finish", data)
}
}
this.events.emit("progress", {
percent: Math.round((100 / this.totalChunks) * this.chunkCount),
state: "Uploading",
})
} catch (error) {
this.events.emit("error", error)
}
}
togglePause() {
this.paused = !this.paused
if (!this.paused) {
return this.nextSend()
}
}
waitOnSSE(data) {
// temporal solution until comty.js manages this
const url = `${app.cores.api.client().mainOrigin}/upload/sse_events/${data.sseChannelId}`
console.log(`[UPLOADER] Connecting to SSE channel >`, url)
const eventSource = new EventSource(url)
eventSource.onerror = (error) => {
this.events.emit("error", error)
eventSource.close()
}
eventSource.onopen = () => {
console.log(`[UPLOADER] SSE channel opened`)
}
eventSource.onmessage = (event) => {
// parse json
const messageData = JSON.parse(event.data)
console.log(`[UPLOADER] SSE Event >`, messageData)
if (messageData.event === "done") {
this.events.emit("finish", messageData.result)
eventSource.close()
}
if (messageData.event === "error") {
this.events.emit("error", messageData.result)
eventSource.close()
}
if (messageData.state) {
this.events.emit("progress", {
percent: messageData.percent,
state: messageData.state,
})
}
}
}
}

View File

@ -1,9 +1,10 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames"
import { DragDropContext, Droppable } from "react-beautiful-dnd"
import { createSwapy } from "swapy" import { createSwapy } from "swapy"
import queuedUploadFile from "@utils/queuedUploadFile"
import FilesModel from "@models/files"
import TrackManifest from "@cores/player/classes/TrackManifest" import TrackManifest from "@cores/player/classes/TrackManifest"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
@ -209,14 +210,14 @@ class TracksManager extends React.Component {
console.log( console.log(
`[${trackManifest.uid}] Founded cover, uploading...`, `[${trackManifest.uid}] Founded cover, uploading...`,
) )
const coverFile = new File( const coverFile = new File(
[trackManifest._coverBlob], [trackManifest._coverBlob],
"cover.jpg", "cover.jpg",
{ type: trackManifest._coverBlob.type }, { type: trackManifest._coverBlob.type },
) )
const coverUpload = const coverUpload = await FilesModel.upload(coverFile)
await app.cores.remoteStorage.uploadFile(coverFile)
trackManifest.cover = coverUpload.url trackManifest.cover = coverUpload.url
} }
@ -243,25 +244,16 @@ class TracksManager extends React.Component {
} }
uploadToStorage = async (req) => { uploadToStorage = async (req) => {
const response = await app.cores.remoteStorage await queuedUploadFile(req.file, {
.uploadFile(req.file, { onFinish: (file, response) => {
req.onSuccess(response)
},
onError: req.onError,
onProgress: this.handleTrackFileUploadProgress, onProgress: this.handleTrackFileUploadProgress,
headers: { headers: {
transformations: "a-dash", transformations: "a-dash",
}, },
}) })
.catch((error) => {
console.error(error)
antd.message.error(error)
req.onError(error)
return false
})
if (response) {
req.onSuccess(response)
}
} }
handleTrackFileUploadProgress = async (file, progress) => { handleTrackFileUploadProgress = async (file, progress) => {

View File

@ -10,6 +10,7 @@ import { Icons } from "@components/Icons"
import Poll from "@components/Poll" import Poll from "@components/Poll"
import clipboardEventFileToFile from "@utils/clipboardEventFileToFile" import clipboardEventFileToFile from "@utils/clipboardEventFileToFile"
import queuedUploadFile from "@utils/queuedUploadFile"
import PostModel from "@models/post" import PostModel from "@models/post"
import SearchModel from "@models/search" import SearchModel from "@models/search"
@ -195,22 +196,14 @@ export default class PostCreator extends React.Component {
uploadFile = async (req) => { uploadFile = async (req) => {
this.toggleUploaderVisibility(false) this.toggleUploaderVisibility(false)
const request = await app.cores.remoteStorage await queuedUploadFile(req.file, {
.uploadFile(req.file) onFinish: (file, response) => {
.catch((error) => { req.onSuccess(response)
console.error(error) },
antd.message.error(error) onError: (file, response) => {
req.onError(response)
req.onError(error) },
return false
}) })
if (request) {
console.log(`Upload done >`, request)
return req.onSuccess(request)
}
} }
removeAttachment = (file_uid) => { removeAttachment = (file_uid) => {

View File

@ -1,12 +1,13 @@
import React from "react" import React from "react"
import { Upload, Progress } from "antd" import { Upload, Progress } from "antd"
import classnames from "classnames" import classnames from "classnames"
import queuedUploadFile from "@utils/queuedUploadFile"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import "./index.less" import "./index.less"
export default (props) => { const UploadButton = (props) => {
const [uploading, setUploading] = React.useState(false) const [uploading, setUploading] = React.useState(false)
const [progress, setProgress] = React.useState(null) const [progress, setProgress] = React.useState(null)
@ -40,17 +41,7 @@ export default (props) => {
handleOnStart(req.file.uid, req.file) handleOnStart(req.file.uid, req.file)
await app.cores.remoteStorage.uploadFile(req.file, { await queuedUploadFile(req.file, {
headers: props.headers,
onProgress: (file, progress) => {
setProgress(progress)
handleOnProgress(file.uid, progress)
},
onError: (file, error) => {
setProgress(null)
handleOnError(file.uid, error)
setUploading(false)
},
onFinish: (file, response) => { onFinish: (file, response) => {
if (typeof props.ctx?.onUpdateItem === "function") { if (typeof props.ctx?.onUpdateItem === "function") {
props.ctx.onUpdateItem(response.url) props.ctx.onUpdateItem(response.url)
@ -67,6 +58,16 @@ export default (props) => {
setProgress(null) setProgress(null)
}, 1000) }, 1000)
}, },
onError: (file, error) => {
setProgress(null)
handleOnError(file.uid, error)
setUploading(false)
},
onProgress: (file, progress) => {
setProgress(progress)
handleOnProgress(file.uid, progress)
},
headers: props.headers,
}) })
} }
@ -106,3 +107,5 @@ export default (props) => {
</Upload> </Upload>
) )
} }
export default UploadButton

View File

@ -1,117 +0,0 @@
import { Core } from "@ragestudio/vessel"
import ChunkedUpload from "@classes/ChunkedUpload"
import SessionModel from "@models/session"
export default class RemoteStorage extends Core {
static namespace = "remoteStorage"
static depends = ["api", "tasksQueue"]
public = {
uploadFile: this.uploadFile,
getFileHash: this.getFileHash,
binaryArrayToFile: this.binaryArrayToFile,
}
binaryArrayToFile(bin, filename) {
const { format, data } = bin
const filenameExt = format.split("/")[1]
filename = `${filename}.${filenameExt}`
const byteArray = new Uint8Array(data)
const blob = new Blob([byteArray], { type: data.type })
return new File([blob], filename, {
type: format,
})
}
async getFileHash(file) {
const buffer = await file.arrayBuffer()
const hash = await crypto.subtle.digest("SHA-256", buffer)
const hashArray = Array.from(new Uint8Array(hash))
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return hashHex
}
async uploadFile(
file,
{
onProgress = () => {},
onFinish = () => {},
onError = () => {},
service = "standard",
headers = {},
} = {},
) {
return await new Promise((_resolve, _reject) => {
const fn = async () =>
new Promise((resolve, reject) => {
const uploader = new ChunkedUpload({
endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
splitChunkSize: 5 * 1024 * 1024,
file: file,
service: service,
headers: {
...headers,
"provider-type": service,
Authorization: `Bearer ${SessionModel.token}`,
},
})
uploader.events.on("error", ({ message }) => {
this.console.error("[Uploader] Error", message)
app.cores.notifications.new(
{
title: "Could not upload file",
description: message,
},
{
type: "error",
},
)
if (typeof onError === "function") {
onError(file, message)
}
reject(message)
_reject(message)
})
uploader.events.on("progress", (data) => {
if (typeof onProgress === "function") {
onProgress(file, data)
}
})
uploader.events.on("finish", (data) => {
this.console.debug("[Uploader] Finish", data)
app.cores.notifications.new(
{
title: "File uploaded",
},
{
type: "success",
},
)
if (typeof onFinish === "function") {
onFinish(file, data)
}
resolve(data)
_resolve(data)
})
})
app.cores.tasksQueue.appendToQueue(`upload_${file.name}`, fn)
})
}
}

View File

@ -1,34 +0,0 @@
import { Core } from "@ragestudio/vessel"
import SyncModel from "comty.js/models/sync"
export default class SyncCore extends Core {
static namespace = "sync"
static dependencies = ["api", "settings"]
activeLinkedServices = {}
services = {
}
public = {
getActiveLinkedServices: function () {
return this.activeLinkedServices
}.bind(this),
services: this.services,
}
events = {
"app.initialization.start": async () => {
const activeServices = await SyncModel.getLinkedServices().catch((error) => {
this.console.error(error)
return null
})
if (activeServices) {
this.console.log(`Active services`, activeServices)
this.activeLinkedServices = activeServices
}
}
}
}

View File

@ -21,7 +21,9 @@ export default class TasksQueue extends Core {
processTasks() { processTasks() {
if (this.runningTasksIds.length >= TasksQueue.maxRunningTasks ?? 1) { if (this.runningTasksIds.length >= TasksQueue.maxRunningTasks ?? 1) {
this.console.log("We are already running the maximum number of tasks") this.console.log(
"We are already running the maximum number of tasks",
)
return false return false
} }
@ -47,17 +49,20 @@ export default class TasksQueue extends Core {
// add the task to the running tasks array // add the task to the running tasks array
this.runningTasksIds.push(task.id) this.runningTasksIds.push(task.id)
const taskResult = await task.fn() const taskResult = await task.fn().catch((error) => {
.catch((error) => {
// delete the task from the running tasks array // delete the task from the running tasks array
this.runningTasksIds = this.runningTasksIds.filter((runningTaskId) => runningTaskId !== task.id) this.runningTasksIds = this.runningTasksIds.filter(
(runningTaskId) => runningTaskId !== task.id,
)
// propagate the error through an exception // propagate the error through an exception
throw error throw error
}) })
// delete the task from the running tasks array // delete the task from the running tasks array
this.runningTasksIds = this.runningTasksIds.filter((runningTaskId) => runningTaskId !== task.id) this.runningTasksIds = this.runningTasksIds.filter(
(runningTaskId) => runningTaskId !== task.id,
)
return taskResult return taskResult
}) })

View File

@ -54,9 +54,9 @@ const AuthPage = (props) => {
<GradientSVG /> <GradientSVG />
</div> </div>
<div className="wrapper"> <div className="login-page-card">
<div <div
className="wrapper_background" className="login-page-card__background"
style={{ style={{
backgroundImage: randomWallpaperURL backgroundImage: randomWallpaperURL
? `url(${randomWallpaperURL})` ? `url(${randomWallpaperURL})`
@ -65,7 +65,7 @@ const AuthPage = (props) => {
}} }}
/> />
<div className="content"> <div className="login-page-card__content">
{React.createElement( {React.createElement(
keyToComponents[activeKey] ?? keyToComponents[activeKey] ??
keyToComponents["selector"], keyToComponents["selector"],

View File

@ -30,15 +30,26 @@
animation: opacityIn 3s ease-in-out 0s forwards; animation: opacityIn 3s ease-in-out 0s forwards;
} }
background-image: radial-gradient(rgba(var(--layout-background-contrast), 0.3) 1px, transparent 0), background-image:
radial-gradient(rgba(var(--layout-background-contrast), 0.3) 1px, transparent 0); radial-gradient(
rgba(var(--layout-background-contrast), 0.3) 1px,
transparent 0
),
radial-gradient(
rgba(var(--layout-background-contrast), 0.3) 1px,
transparent 0
);
background-position: 0 0, background-position:
0 0,
25px 25px; 25px 25px;
background-size: 50px 50px; background-size: 50px 50px;
} }
}
.wrapper { @login-page-card-img-width: 250px;
.login-page-card {
position: relative; position: relative;
z-index: 15; z-index: 15;
@ -49,48 +60,63 @@
min-width: 700px; min-width: 700px;
max-width: 800px; max-width: 800px;
overflow: hidden;
height: 50vh; height: 50vh;
min-height: 500px; min-height: 500px;
max-height: 500px; max-height: 500px;
background-color: var(--background-color-accent);
border: 3px solid var(--border-color);
border-radius: 12px;
transition: all 250ms ease-in-out; transition: all 250ms ease-in-out;
background-color: var(--background-color-accent); overflow: hidden;
border: 1px solid var(--border-color); &__background {
position: absolute;
border-radius: 12px; left: 0;
top: 0;
.wrapper_background {
height: 100%; height: 100%;
min-width: 250px; width: calc(@login-page-card-img-width + 10px);
width: 250px; min-width: calc(@login-page-card-img-width + 10px);
border-radius: 12px;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
z-index: 1;
} }
.content { &__content {
position: absolute;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
right: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: calc(100% - @login-page-card-img-width);
min-width: 420px; min-width: 420px;
gap: 20px; height: 100%;
gap: 20px;
padding: 40px; padding: 40px;
outline: 3px solid var(--border-color-solid);
border-radius: 12px;
background-color: var(--background-color-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
z-index: 2;
.content_header { .content_header {
height: 70px; height: 70px;
@ -123,7 +149,6 @@
} }
} }
} }
}
} }
@keyframes opacityIn { @keyframes opacityIn {

View File

@ -0,0 +1,46 @@
import FilesModel from "@models/files"
export default (file, options) => {
if (!app.cores.tasksQueue) {
throw new Error("Missing tasksQueue")
}
return app.cores.tasksQueue.appendToQueue(
`upload_${file.name}`,
async () => {
await FilesModel.upload(file, {
...options,
onError: (file, error) => {
app.cores.notifications.new(
{
title: "Could not upload file",
description: error.message,
},
{
type: "error",
},
)
if (typeof options.onError === "function") {
options.onError(file, error)
}
},
onFinish: (file, data) => {
app.cores.notifications.new(
{
title: "File uploaded",
description: `[${file.name}] uploaded successfully!`,
},
{
type: "success",
},
)
if (typeof options.onFinish === "function") {
options.onFinish(file, data)
}
},
})
},
)
}