feat(submit page): add media file upload

On desktop or android, the user can drop or select a media file in the upload box, which automatically uploads it to catbox.moe in the background, and pastes the direct link to the media in the url field.
This commit is contained in:
Tom (plebeius.eth)
2025-03-19 21:40:24 +01:00
parent 4509713e04
commit dc43aeb993
21 changed files with 1061 additions and 24 deletions

View File

@@ -42,6 +42,8 @@ dependencies {
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation "org.apache.cordova:framework:$cordovaAndroidVersion"
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okio:okio:3.9.0'
}
apply from: 'capacitor.build.gradle'

View File

@@ -6,6 +6,12 @@
"plugins": {
"CapacitorHttp": {
"enabled": true
},
"FileUploader": {
"enabled": true
}
},
"server": {
"androidScheme": "https"
}
}

View File

@@ -0,0 +1,268 @@
package seedit.android;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.provider.MediaStore;
import android.util.Log;
import android.database.Cursor;
import android.provider.OpenableColumns;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.PluginCall;
import androidx.activity.result.ActivityResult;
import java.io.File;
import java.io.IOException;
import java.io.FileOutputStream;
import java.util.concurrent.TimeUnit;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@CapacitorPlugin(name = "FileUploader")
public class FileUploaderPlugin extends Plugin {
private static final String TAG = "FileUploaderPlugin";
@PluginMethod
public void pickAndUploadMedia(PluginCall call) {
Log.d(TAG, "pickAndUploadMedia called");
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"};
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
startActivityForResult(call, intent, "pickFileResult");
}
@ActivityCallback
private void pickFileResult(PluginCall call, ActivityResult result) {
Log.d(TAG, "pickFileResult callback received");
if (call == null) {
return;
}
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
uploadToCatbox(uri, call);
} else {
call.reject("No data received");
}
} else {
call.reject("File selection cancelled");
}
}
private void uploadToCatbox(Uri fileUri, PluginCall call) {
new Thread(() -> {
try {
Log.d(TAG, "Starting file conversion from URI");
File file = FileUtils.getFileFromUri(getContext(), fileUri);
Log.d(TAG, "File name: " + file.getName());
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to catbox.moe...");
notifyListeners("uploadStatus", statusUpdate);
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("reqtype", "fileupload")
.addFormDataPart("fileToUpload", file.getName(),
RequestBody.create(MediaType.parse("application/octet-stream"), file))
.build();
Request request = new Request.Builder()
.url("https://catbox.moe/user/api.php")
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) throw new IOException("Unexpected response " + response);
String url = response.body().string();
Log.d(TAG, "Upload successful. URL: " + url);
JSObject ret = new JSObject();
ret.put("url", url);
ret.put("fileName", file.getName());
ret.put("status", "Upload complete!");
call.resolve(ret);
}
} catch (Exception e) {
Log.e(TAG, "Upload failed", e);
call.reject("Upload failed: " + e.getMessage());
}
}).start();
}
@PluginMethod
public void uploadMedia(PluginCall call) {
if (!call.getData().has("fileData") || !call.getData().has("fileName")) {
call.reject("Missing required parameters fileData or fileName");
return;
}
String fileData = call.getString("fileData");
String fileName = call.getString("fileName");
// Execute in background thread to avoid blocking UI
new Thread(() -> {
try {
// Convert base64 to file
byte[] decodedBytes = android.util.Base64.decode(fileData, android.util.Base64.DEFAULT);
File tempFile = new File(getContext().getCacheDir(), fileName);
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
fos.write(decodedBytes);
}
// Create multipart upload form
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("reqtype", "fileupload")
.addFormDataPart("fileToUpload", fileName,
RequestBody.create(MediaType.parse("application/octet-stream"), tempFile))
.build();
Request request = new Request.Builder()
.url("https://catbox.moe/user/api.php")
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) throw new IOException("Unexpected response " + response);
String url = response.body().string();
// Return the result to JavaScript
JSObject ret = new JSObject();
ret.put("url", url);
ret.put("fileName", fileName);
getActivity().runOnUiThread(() -> {
call.resolve(ret);
});
// Clean up temp file
tempFile.delete();
}
} catch (Exception e) {
getActivity().runOnUiThread(() -> {
call.reject("Upload failed: " + e.getMessage(), e);
});
}
}).start();
}
@PluginMethod
public void pickMedia(PluginCall call) {
Log.d(TAG, "pickMedia called");
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"};
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
startActivityForResult(call, intent, "pickMediaResult");
}
@ActivityCallback
private void pickMediaResult(PluginCall call, ActivityResult result) {
Log.d(TAG, "pickMediaResult callback received");
if (call == null) {
return;
}
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
try {
// Get file name
String fileName = getFileNameFromUri(uri);
// Get mime type
String mimeType = getContext().getContentResolver().getType(uri);
if (mimeType == null) {
mimeType = "application/octet-stream";
}
// Read file into a byte array
InputStream inputStream = getContext().getContentResolver().openInputStream(uri);
ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
byteBuffer.write(buffer, 0, len);
}
byte[] fileBytes = byteBuffer.toByteArray();
inputStream.close();
// Convert to base64
String base64Data = android.util.Base64.encodeToString(fileBytes, android.util.Base64.DEFAULT);
// Return data to JavaScript
JSObject ret = new JSObject();
ret.put("data", base64Data);
ret.put("fileName", fileName);
ret.put("mimeType", mimeType);
call.resolve(ret);
} catch (Exception e) {
call.reject("Failed to read file: " + e.getMessage(), e);
}
} else {
call.reject("No data received");
}
} else {
call.reject("File selection cancelled");
}
}
// Helper method to get file name from URI
private String getFileNameFromUri(Uri uri) {
String result = null;
if (uri.getScheme().equals("content")) {
Cursor cursor = getContext().getContentResolver().query(uri, null, null, null, null);
try {
if (cursor != null && cursor.moveToFirst()) {
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (nameIndex >= 0) {
result = cursor.getString(nameIndex);
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
}
if (result == null) {
result = uri.getPath();
int cut = result.lastIndexOf('/');
if (cut != -1) {
result = result.substring(cut + 1);
}
}
return result;
}
}

View File

@@ -0,0 +1,50 @@
package seedit.android;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
public class FileUtils {
public static File getFileFromUri(Context context, Uri uri) throws Exception {
String fileName = getFileName(context, uri);
File file = new File(context.getCacheDir(), fileName);
try (InputStream inputStream = context.getContentResolver().openInputStream(uri);
FileOutputStream outputStream = new FileOutputStream(file)) {
byte[] buffer = new byte[4096];
int length;
while ((length = inputStream.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
outputStream.flush();
return file;
}
}
private static String getFileName(Context context, Uri uri) {
String result = null;
if (uri.getScheme().equals("content")) {
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (columnIndex != -1) {
result = cursor.getString(columnIndex);
}
}
}
}
if (result == null) {
result = uri.getPath();
int cut = result.lastIndexOf('/');
if (cut != -1) {
result = result.substring(cut + 1);
}
}
return result;
}
}

View File

@@ -1,5 +1,12 @@
package seedit.android;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(FileUploaderPlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -7,8 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.android.tools.build:gradle:8.7.0'
classpath 'com.google.gms:google-services:4.3.10'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@@ -22,3 +22,6 @@ org.gradle.jvmargs=-Xmx1536m
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -8,7 +8,10 @@ const config: CapacitorConfig = {
plugins: {
CapacitorHttp: {
enabled: true,
}
},
FileUploader: {
enabled: true,
},
},
server: {
androidScheme: 'https'

View File

@@ -1,15 +1,21 @@
import './log.js';
import { app, BrowserWindow, Menu, MenuItem, Tray, screen as electronScreen, shell, dialog, nativeTheme, ipcMain } from 'electron';
import { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain } from 'electron';
import isDev from 'electron-is-dev';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import EnvPaths from 'env-paths';
import startIpfs from './start-ipfs.js';
import './start-plebbit-rpc.js';
import { URL, fileURLToPath } from 'node:url';
import { URL } from 'node:url';
import contextMenu from 'electron-context-menu';
import packageJson from '../package.json' with { type: 'json' };
const dirname = path.join(path.dirname(fileURLToPath(import.meta.url)));
import FormData from 'form-data';
import fetch from 'node-fetch';
import { createReadStream } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
let startIpfsError;
startIpfs.onError = (error) => {
@@ -78,7 +84,8 @@ const createMainWindow = () => {
nodeIntegration: false,
contextIsolation: true,
devTools: true, // TODO: change to isDev when no bugs left
preload: path.join(dirname, 'preload.js'),
preload: path.join(dirname, 'preload.mjs'),
sandbox: false, // Required for ESM preload scripts
},
});
@@ -316,3 +323,117 @@ app.on('window-all-closed', () => {
app.quit();
}
});
// Setup FileUploader plugin
ipcMain.handle('plugin:file-uploader:pickAndUploadMedia', async (event) => {
try {
const mainWindow = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }],
});
if (result.canceled || result.filePaths.length === 0) {
throw new Error('File selection cancelled');
}
const filePath = result.filePaths[0];
const fileName = path.basename(filePath);
// Create form data for upload
const formData = new FormData();
formData.append('reqtype', 'fileupload');
formData.append('fileToUpload', createReadStream(filePath));
// Upload to catbox.moe
const response = await fetch('https://catbox.moe/user/api.php', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const url = await response.text();
return { url, fileName };
} catch (error) {
console.error('FileUploader error:', error);
throw error;
}
});
ipcMain.handle('plugin:file-uploader:uploadMedia', async (event, fileData) => {
try {
console.log('uploadMedia handler called with data:', typeof fileData);
// Create form data for upload
const formData = new FormData();
formData.append('reqtype', 'fileupload');
// Handle different types of inputs
if (fileData.fileData && fileData.fileName) {
// Convert base64 to buffer
const buffer = Buffer.from(fileData.fileData, 'base64');
formData.append('fileToUpload', buffer, fileData.fileName);
} else {
throw new Error('Invalid file data');
}
// Upload to catbox.moe
const response = await fetch('https://catbox.moe/user/api.php', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const url = await response.text();
return { url, fileName: fileData.fileName || 'uploaded-file' };
} catch (error) {
console.error('FileUploader uploadMedia error:', error);
throw error;
}
});
// Add the pickMedia handler
ipcMain.handle('plugin:file-uploader:pickMedia', async (event) => {
try {
const mainWindow = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }],
});
if (result.canceled || result.filePaths.length === 0) {
throw new Error('File selection cancelled');
}
const filePath = result.filePaths[0];
const fileName = path.basename(filePath);
// Read the file as base64
const fileBuffer = fs.readFileSync(filePath);
const base64Data = fileBuffer.toString('base64');
// Determine mime type from extension
const ext = path.extname(fileName).toLowerCase();
let mimeType = 'application/octet-stream';
if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg';
else if (ext === '.png') mimeType = 'image/png';
else if (ext === '.gif') mimeType = 'image/gif';
else if (ext === '.mp4') mimeType = 'video/mp4';
else if (ext === '.webm') mimeType = 'video/webm';
return {
data: base64Data,
fileName,
mimeType,
};
} catch (error) {
console.error('FileUploader pickMedia error:', error);
throw error;
}
});

View File

@@ -1,4 +1,9 @@
const { contextBridge, ipcRenderer } = require('electron');
import { contextBridge, ipcRenderer } from 'electron';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// dev uses http://localhost, prod uses file://...index.html
const isDev = window.location.protocol === 'http:';
@@ -17,3 +22,19 @@ ipcRenderer.send('get-plebbit-rpc-auth-key');
// uncomment for logs
// localStorage.debug = 'plebbit-js:*,plebbit-react-hooks:*,seedit:*'
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
invoke: (channel, ...args) => {
const validChannels = [
'plugin:file-uploader:pickAndUploadMedia',
'plugin:file-uploader:uploadMedia',
'plugin:file-uploader:pickMedia'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Unauthorized IPC channel: ${channel}`);
}
});

60
electron/src/main.ts Normal file
View File

@@ -0,0 +1,60 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { setupFileUploaderPlugin } from './plugins/file-uploader';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
});
// Prevent file drops on the window
mainWindow.webContents.on('will-navigate', (e, url) => {
if (url !== mainWindow.webContents.getURL()) {
e.preventDefault();
}
});
// Prevent default file drop behavior
mainWindow.webContents.on('drop', (e) => {
e.preventDefault();
});
mainWindow.webContents.on('dragover', (e) => {
e.preventDefault();
});
// Setup the FileUploader plugin
setupFileUploaderPlugin(mainWindow);
// Load your app
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../build/index.html'));
}
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

View File

@@ -0,0 +1,128 @@
import { dialog, ipcMain } from 'electron';
import { createReadStream } from 'fs';
import fetch from 'node-fetch';
import FormData from 'form-data';
import path from 'path';
import fs from 'fs';
import { app } from 'electron';
export function setupFileUploaderPlugin(mainWindow: Electron.BrowserWindow) {
// Handle the file upload request from the renderer
ipcMain.handle('plugin:file-uploader:pickAndUploadMedia', async () => {
try {
// Open file dialog
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }],
});
if (result.canceled || result.filePaths.length === 0) {
throw new Error('File selection cancelled');
}
const filePath = result.filePaths[0];
const fileName = path.basename(filePath);
// Create form data for upload
const formData = new FormData();
formData.append('reqtype', 'fileupload');
formData.append('fileToUpload', createReadStream(filePath));
// Upload to catbox.moe
const response = await fetch('https://catbox.moe/user/api.php', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const url = await response.text();
return { url, fileName };
} catch (error) {
console.error('FileUploader error:', error);
throw error;
}
});
// Add the uploadMedia handler
ipcMain.handle('plugin:file-uploader:uploadMedia', async (_, fileData) => {
try {
const formData = new FormData();
formData.append('reqtype', 'fileupload');
if (fileData.fileData && fileData.fileName) {
// If we have base64 data from pickMedia
const tempFilePath = path.join(app.getPath('temp'), fileData.fileName);
// Write the base64 data to a temp file
fs.writeFileSync(tempFilePath, Buffer.from(fileData.fileData, 'base64'));
// Append the file to the form
formData.append('fileToUpload', createReadStream(tempFilePath));
// Upload to catbox.moe
const response = await fetch('https://catbox.moe/user/api.php', {
method: 'POST',
body: formData,
});
// Clean up temp file
fs.unlinkSync(tempFilePath);
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const url = await response.text();
return { url, fileName: fileData.fileName };
} else {
throw new Error('Invalid file data');
}
} catch (error) {
console.error('FileUploader uploadMedia error:', error);
throw error;
}
});
// Add the pickMedia handler to Electron
ipcMain.handle('plugin:file-uploader:pickMedia', async () => {
try {
// Open file dialog
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }],
});
if (result.canceled || result.filePaths.length === 0) {
throw new Error('File selection cancelled');
}
const filePath = result.filePaths[0];
const fileName = path.basename(filePath);
// Read the file as base64
const fileBuffer = fs.readFileSync(filePath);
const base64Data = fileBuffer.toString('base64');
// Determine mime type from extension
const ext = path.extname(fileName).toLowerCase();
let mimeType = 'application/octet-stream';
if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg';
else if (ext === '.png') mimeType = 'image/png';
else if (ext === '.gif') mimeType = 'image/gif';
else if (ext === '.mp4') mimeType = 'video/mp4';
else if (ext === '.webm') mimeType = 'video/webm';
return {
data: base64Data,
fileName,
mimeType,
};
} catch (error) {
console.error('FileUploader pickMedia error:', error);
throw error;
}
});
}

14
electron/src/preload.ts Normal file
View File

@@ -0,0 +1,14 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
invoke: (channel: string, ...args: any[]) => {
// whitelist channels
const validChannels = ['plugin:file-uploader:pickAndUploadMedia', 'plugin:file-uploader:uploadMedia', 'plugin:file-uploader:pickMedia'];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Unauthorized IPC channel: ${channel}`);
},
});

View File

@@ -21,15 +21,18 @@
"electron-context-menu": "3.3.0",
"electron-is-dev": "2.0.0",
"ext-name": "5.0.0",
"form-data": "4.0.2",
"i18next": "23.5.1",
"i18next-browser-languagedetector": "7.1.0",
"i18next-http-backend": "2.2.2",
"json-stringify-pretty-compact": "4.0.0",
"lodash": "4.17.21",
"memoizee": "0.4.15",
"node-fetch": "2",
"prettier": "3.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-dropzone": "14.3.8",
"react-i18next": "13.2.2",
"react-markdown": "8.0.6",
"react-router-dom": "6.16.0",
@@ -84,6 +87,7 @@
"@capacitor/core": "5.0.0",
"@electron/rebuild": "3.6.0",
"@types/memoizee": "0.4.9",
"@types/node-fetch": "2",
"@typescript-eslint/eslint-plugin": "8.26.0",
"@typescript-eslint/parser": "8.26.0",
"@vitejs/plugin-react": "4.3.4",

BIN
public/assets/camera.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/assets/upload.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,52 @@
import { registerPlugin } from '@capacitor/core';
import { Capacitor } from '@capacitor/core';
export interface FileUploaderPlugin {
pickAndUploadMedia(): Promise<{ url: string; fileName: string }>;
uploadMedia(fileData: { fileData?: string; fileName: string } | File): Promise<{ url: string; fileName: string }>;
pickMedia(): Promise<{ data: string; fileName: string; mimeType: string }>;
}
const FileUploader = Capacitor.isNativePlatform()
? registerPlugin<FileUploaderPlugin>('FileUploader')
: {
pickAndUploadMedia: async () => {
// For Electron, we'll use IPC
if (window.electron) {
return window.electron.invoke('plugin:file-uploader:pickAndUploadMedia');
}
throw new Error('FileUploader is not supported on this platform');
},
uploadMedia: async (fileData: { fileData?: string; fileName: string } | File) => {
if (window.electron) {
return window.electron.invoke('plugin:file-uploader:uploadMedia', fileData);
}
throw new Error('FileUploader is not supported on this platform');
},
pickMedia: async () => {
console.log('Calling pickMedia');
if (window.electron) {
console.log('window.electron exists, invoking IPC');
try {
const result = await window.electron.invoke('plugin:file-uploader:pickMedia');
console.log('IPC result:', result);
return result;
} catch (error) {
console.error('IPC error:', error);
throw error;
}
}
throw new Error('FileUploader is not supported on this platform');
},
};
export default FileUploader;
// Add TypeScript type declaration for Electron
declare global {
interface Window {
electron?: {
invoke(channel: string, ...args: any[]): Promise<any>;
};
}
}

View File

@@ -75,6 +75,8 @@
--thumbnail-icon-nsfw: url('/assets/thumbnail-icon-nsfw-dark.png');
--thumbnail-icon-spoiler: url('/assets/thumbnail-icon-spoiler-dark.png');
--thumbnail-icon-text: url('/assets/thumbnail-icon-text-dark.png');
--upload-box-background: rgb(15, 15, 15);
--upload-box-border: 2px dashed #404649;
--yellow: rgb(200, 171, 0);
--yellow-box-background: rgb(56, 45, 0);
--yellow-box-contrast: rgb(163, 130, 0);
@@ -162,6 +164,8 @@
--thumbnail-icon-nsfw: url('/assets/thumbnail-icon-nsfw.png');
--thumbnail-icon-spoiler: url('/assets/thumbnail-icon-spoiler.png');
--thumbnail-icon-text: url('/assets/thumbnail-icon-text.png');
--upload-box-background: #fafafa;
--upload-box-border: 2px dashed #bbb;
--yellow: goldenrod;
--yellow-box-background: #fff7d7;
--yellow-box-contrast: #ffd634;

View File

@@ -1,6 +1,7 @@
.content {
margin: 7px 5px 50px 5px;
color: var(--text);
cursor: default;
}
.infobar {
@@ -189,6 +190,22 @@
text-transform: capitalize;
}
.urlCancelButton {
display: block;
position: absolute;
right: 6px;
top: 6px;
height: 18px;
width: 18px;
text-align: center;
background: var(--gray-light);
line-height: 18px;
font-size: 11px;
border-radius: 50%;
cursor: pointer;
z-index: 1;
}
.logoError {
padding-left: 10px;
}
@@ -232,4 +249,99 @@
.input {
min-width: 100%;
}
}
}
.uploadBox {
position: relative;
content: '';
width: 100%;
box-sizing: border-box;
padding: 20px 10px;
border: var(--upload-box-border);
background-color: var(--upload-box-background);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.uploadBox.dragging {
border-width: 5px;
}
.uploading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
}
.cameraIcon {
background-image: url('/assets/camera.png');
display: inline-block;
width: 54px;
height: 40px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
@media (max-width: 640px) {
.cameraIcon {
width: 40px;
height: 30px;
}
}
.dropText {
display: inline;
padding: 0 5px;
}
@media (max-width: 640px) {
.dropText {
font-size: 15px;
}
}
.fileUploadIcon {
background-image: url('/assets/upload.png');
display: inline-block;
width: 20px;
height: 16px;
margin-right: 5px;
background-size: contain;
pointer-events: none;
vertical-align: middle;
background-repeat: no-repeat;
transform: translateY(-1px);
}
.uploadBox label {
color: var(--over18-alert-color);
display: inline-block;
text-align: center;
text-transform: uppercase;
font-weight: 500;
cursor: pointer;
background-image: none;
border: 1px solid transparent;
white-space: nowrap;
padding: 5px 12px 3px 12px;
font-size: 12px;
border-radius: 3px;
margin: 3px 5px;
background-color: var(--button-background-color);
border-bottom: 2px solid var(--button-border-bottom);
user-select: none;
text-transform: uppercase;
font-weight: 700;
font-size: 13px;
}
.uploadBox label:hover {
background-color: var(--button-background-color-hover);
}

View File

@@ -1,22 +1,25 @@
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Capacitor } from '@capacitor/core';
import { useDropzone } from 'react-dropzone';
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
import { useAccount, usePublishComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import usePublishPostStore from '../../stores/use-publish-post-store';
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import Embed from '../../components/post/embed';
import LoadingEllipsis from '../../components/loading-ellipsis';
import Markdown from '../../components/markdown';
import FileUploader from '../../plugins/file-uploader';
import styles from './submit-page.module.css';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
const UrlField = () => {
const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string) => void; urlRef: React.RefObject<HTMLInputElement> }) => {
const { t } = useTranslation();
const { setPublishPostStore } = usePublishPostStore();
const [mediaError, setMediaError] = useState(false);
const [url, setUrl] = useState('');
const mediaInfo = getLinkMediaInfo(url);
const mediaType = mediaInfo?.type;
@@ -46,7 +49,13 @@ const UrlField = () => {
</>
)}
<div className={styles.boxContent}>
{url && (
<span className={styles.urlCancelButton} onClick={() => setUrl('')}>
x
</span>
)}
<input
ref={urlRef}
className={`${styles.input} ${styles.inputUrl}`}
type='text'
value={url ?? ''}
@@ -69,6 +78,9 @@ const UrlField = () => {
);
};
const isAndroid = Capacitor.getPlatform() === 'android';
const isElectron = window.isElectron === true;
const Submit = () => {
const { t } = useTranslation();
const params = useParams();
@@ -77,6 +89,8 @@ const Submit = () => {
const [inputAddress, setInputAddress] = useState(params.subplebbitAddress || '');
const [selectedSubplebbit, setSelectedSubplebbit] = useState(params.subplebbitAddress || '');
const [url, setUrl] = useState('');
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
@@ -162,14 +176,14 @@ const Submit = () => {
if (activeDropdownIndex !== -1) {
const selectedAddress = filteredSubplebbitAddresses[activeDropdownIndex];
setSelectedSubplebbit(selectedAddress);
setPublishPostStore({ subplebbitAddress: selectedAddress });
setSubmitStoreHook({ subplebbitAddress: selectedAddress });
setInputAddress(selectedAddress);
}
setActiveDropdownIndex(-1);
setIsInputAddressFocused(false);
}
},
[filteredSubplebbitAddresses, activeDropdownIndex, setPublishPostStore],
[filteredSubplebbitAddresses, activeDropdownIndex, setSubmitStoreHook],
);
useEffect(() => {
@@ -217,11 +231,130 @@ const Submit = () => {
const handleSubplebbitSelect = (subplebbitAddress: string) => {
setSelectedSubplebbit(subplebbitAddress);
setInputAddress(subplebbitAddress);
setPublishPostStore({ subplebbitAddress: subplebbitAddress });
setSubmitStoreHook({ subplebbitAddress: subplebbitAddress });
setIsInputAddressFocused(false);
setActiveDropdownIndex(-1);
};
// on android or electron, auto upload file to image hosting sites with open api
const [isUploading, setIsUploading] = useState(false);
const [isChoosingFile, setIsChoosingFile] = useState(false);
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (!(isAndroid || isElectron)) {
if (window.confirm('This feature is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to download links page on GitHub?')) {
const link = document.createElement('a');
link.href = 'https://github.com/plebbit/seedit/releases/latest';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.click();
}
return;
}
if (acceptedFiles.length > 0) {
try {
setIsChoosingFile(false);
setIsUploading(true);
// for Electron, we need to convert the File to a format that can be sent via IPC
if (isElectron) {
const file = acceptedFiles[0];
const reader = new FileReader();
const fileData = await new Promise((resolve, reject) => {
reader.onload = () => {
const base64data = reader.result?.toString().split(',')[1];
resolve({
fileData: base64data,
fileName: file.name,
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const result = await FileUploader.uploadMedia(fileData as { fileData?: string; fileName: string });
if (result.url) {
setUrl(result.url);
setPublishPostStore({ link: result.url || undefined });
}
} else if (isAndroid) {
// android can handle File objects directly
const result = await FileUploader.uploadMedia(acceptedFiles[0]);
if (result.url) {
setUrl(result.url);
setPublishPostStore({ link: result.url || undefined });
}
}
} catch (error) {
console.error('Upload failed:', error);
if (error instanceof Error && !error.message.includes('File selection cancelled')) {
alert(`${t('upload_failed')}: ${error.message}`);
}
} finally {
setIsUploading(false);
}
}
},
[setUrl, setPublishPostStore, t],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
noClick: true,
accept: {
'image/*': [],
'video/*': [],
'audio/*': [],
},
});
const handleUpload = async () => {
if (!(isAndroid || isElectron)) {
if (window.confirm('This feature is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to download links page on GitHub?')) {
const link = document.createElement('a');
link.href = 'https://github.com/plebbit/seedit/releases/latest';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.click();
}
return;
}
try {
setIsChoosingFile(true);
const pickedFile = await FileUploader.pickMedia(); // base64 data
setIsChoosingFile(false);
setIsUploading(true);
const uploadResult = await FileUploader.uploadMedia({
fileData: pickedFile.data,
fileName: pickedFile.fileName,
});
if (uploadResult?.url) {
setUrl(uploadResult.url);
setPublishPostStore({ link: uploadResult.url });
} else {
throw new Error('No URL returned from upload');
}
} catch (error) {
console.error('Process failed:', error);
if (error instanceof Error && !error.message.includes('File selection cancelled')) {
alert(`${t('upload_failed')}: ${error.message}`);
} else if (typeof error === 'string' && !error.includes('File selection cancelled')) {
alert(`${t('upload_failed')}: ${error}`);
}
} finally {
setIsChoosingFile(false);
setIsUploading(false);
}
};
return (
<div className={styles.content}>
<h1>
@@ -231,17 +364,37 @@ const Submit = () => {
values={{
link: selectedSubplebbitData?.title || selectedSubplebbitData?.shortAddress || 'seedit',
}}
components={{
1: selectedSubplebbitData?.shortAddress ? <Link to={`/p/${selectedSubplebbit}`} className={styles.location} /> : <></>,
}}
components={[selectedSubplebbitData?.shortAddress ? <Link key='link' to={`/p/${selectedSubplebbit}`} className={styles.location} /> : <span key='link' />]}
/>
</h1>
<div className={styles.form}>
<div className={styles.formContent}>
{isOffline && selectedSubplebbit && <div className={styles.infobar}>{offlineTitle}</div>}
<div className={styles.box}>
<UrlField />
<UrlField url={url} setUrl={setUrl} urlRef={urlRef} />
</div>
{url.length === 0 && (
<div className={styles.box}>
<span className={styles.boxTitleOptional}>image/video/audio</span>
<div className={styles.boxContent}>
{isUploading ? (
<div className={styles.uploading}>
<LoadingEllipsis string={t('uploading')} />
</div>
) : (
<div {...getRootProps()} className={`${styles.uploadBox} ${isDragActive ? styles.dragging : ''}`}>
<input {...getInputProps()} />
<div className={styles.cameraIcon} />
<div className={styles.dropText}>Drop here or</div>
<label onClick={() => (isUploading || isChoosingFile ? null : handleUpload())}>
<div className={styles.fileUploadIcon} />
{t('choose_file')}
</label>
</div>
)}
</div>
</div>
)}
<div className={styles.box}>
<span className={styles.boxTitleRequired}>{t('title')}</span>
<div className={styles.boxContent}>

View File

@@ -3753,6 +3753,14 @@
resolved "https://registry.yarnpkg.com/@types/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.3.tgz#94e247168a18342477639126da8f01353437e8d0"
integrity sha512-QvlqvYtGBYIDeO8dFdY4djkRubcrc+yTJtBc7n8VZPlJDUS/00A+PssbvERM8f9bYRmcaSEHPZgZojeQj7kzAA==
"@types/node-fetch@2":
version "2.6.12"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03"
integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==
dependencies:
"@types/node" "*"
form-data "^4.0.0"
"@types/node@*", "@types/node@>=13.7.0", "@types/node@^22.7.7":
version "22.13.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4"
@@ -4557,6 +4565,11 @@ atob@^2.1.2:
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
attr-accept@^2.2.4:
version "2.2.5"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e"
integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==
available-typed-arrays@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
@@ -7388,6 +7401,13 @@ file-entry-cache@^6.0.1:
dependencies:
flat-cache "^3.0.4"
file-selector@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==
dependencies:
tslib "^2.7.0"
file-type@16.5.4:
version "16.5.4"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd"
@@ -7519,7 +7539,7 @@ foreground-child@^3.1.0:
cross-spawn "^7.0.6"
signal-exit "^4.0.1"
form-data@^4.0.0:
form-data@^4.0.0, form-data@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c"
integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==
@@ -10714,7 +10734,7 @@ node-domexception@^2.0.1:
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-2.0.1.tgz#83b0d101123b5bbf91018fd569a58b88ae985e5b"
integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w==
node-fetch@^2.6.1, node-fetch@^2.6.11, node-fetch@^2.6.7, node-fetch@^2.7.0:
node-fetch@2, node-fetch@^2.6.1, node-fetch@^2.6.11, node-fetch@^2.6.7, node-fetch@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@@ -11710,6 +11730,15 @@ react-dom@19.0.0:
dependencies:
scheduler "^0.25.0"
react-dropzone@^14.3.8:
version "14.3.8"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582"
integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==
dependencies:
attr-accept "^2.2.4"
file-selector "^2.1.0"
prop-types "^15.8.1"
react-i18next@13.2.2:
version "13.2.2"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-13.2.2.tgz#b1e78ed66a54f4bc819616f68b98221e1b1a1936"
@@ -13354,7 +13383,7 @@ tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1:
tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==