FileUpload: UI stuff (#1043)
* FileUpload: use XMLHttpRequest instead of fetch * FileUpload: improve the file host picker settings UI * FileUpload: track the upload progress instead of fallback count * fix lint * fix moreStickers * Update stickers.ts --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
16c9227074
commit
a0c059e4ad
6 changed files with 420 additions and 162 deletions
|
|
@ -104,6 +104,21 @@ function handlePaste(event: ClipboardEvent) {
|
|||
void uploadProvidedFiles(files);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
const ProgressBarInner = () => {
|
||||
const [state, setState] = useState(getUploadState);
|
||||
|
||||
|
|
@ -112,6 +127,9 @@ const ProgressBarInner = () => {
|
|||
if (state.phase === "idle") return null;
|
||||
|
||||
const percentage = Math.max(0, Math.min(100, state.percent));
|
||||
const progressLabel = state.totalBytes > 0
|
||||
? `${Math.round(percentage)}% - ${formatBytes(state.transferredBytes)} of ${formatBytes(state.totalBytes)}`
|
||||
: `${Math.round(percentage)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -123,6 +141,9 @@ const ProgressBarInner = () => {
|
|||
{state.status || "Uploading..."}
|
||||
</div>
|
||||
<div className={cl("progress-meta")}>
|
||||
<span className={cl("progress-percent")}>
|
||||
{progressLabel}
|
||||
</span>
|
||||
<span className={cl("progress-attempt")}>
|
||||
{state.attempt > 0 && state.totalAttempts > 0 ? `${state.attempt}/${state.totalAttempts}` : ""}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ function SettingTextInput(props: {
|
|||
const { description, name, onChange, placeholder, value } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection name={name} description={description}>
|
||||
<SettingsSection name={name} description={description ?? ""}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
|
@ -295,6 +295,60 @@ function SettingTextInput(props: {
|
|||
);
|
||||
}
|
||||
|
||||
function SettingGroup(props: {
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
name: string;
|
||||
}) {
|
||||
const { children, description, name } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection name={name} description={description ?? ""}>
|
||||
<div className={cl("group")}>
|
||||
{children}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingSwitch(props: {
|
||||
checked: boolean;
|
||||
description: string;
|
||||
name: string;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
const { checked, description, name, onChange } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection tag="label" name={name} description={description} inlineSetting>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicePicker(props: {
|
||||
onChange: (service: ServiceType) => void;
|
||||
value: ServiceType;
|
||||
}) {
|
||||
const { onChange, value } = props;
|
||||
|
||||
return (
|
||||
<div className={cl("service-grid")}>
|
||||
{serviceOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cl("service-option")}
|
||||
data-selected={option.value === value}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<span className={cl("service-option-label")}>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackOrderSettings() {
|
||||
const update = useForceUpdater();
|
||||
const { store } = settings;
|
||||
|
|
@ -413,21 +467,18 @@ export function SettingsComponent() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="Service Type" description="The upload service to use">
|
||||
<Select
|
||||
options={serviceOptions}
|
||||
isSelected={v => v === store.serviceType}
|
||||
select={v => {
|
||||
store.serviceType = v;
|
||||
<SettingsSection name="Upload Service" description="Choose where FileUpload sends new files.">
|
||||
<ServicePicker
|
||||
value={store.serviceType as ServiceType}
|
||||
onChange={service => {
|
||||
store.serviceType = service;
|
||||
update();
|
||||
}}
|
||||
serialize={v => v}
|
||||
placeholder="Select a service"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{isZipline && (
|
||||
<>
|
||||
<SettingGroup name="Zipline" description="Connection details for your Zipline instance.">
|
||||
<SettingTextInput
|
||||
name="Service URL"
|
||||
description="The URL of your Zipline instance"
|
||||
|
|
@ -449,31 +500,35 @@ export function SettingsComponent() {
|
|||
onChange={v => store.folderId = v}
|
||||
placeholder="Leave empty for no folder"
|
||||
/>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isEzHost && (
|
||||
<SettingTextInput
|
||||
name="E-Z Host API Key"
|
||||
description="Your E-Z Host API key"
|
||||
value={store.ezHostKey}
|
||||
onChange={v => store.ezHostKey = v}
|
||||
placeholder="Your E-Z Host API key"
|
||||
/>
|
||||
<SettingGroup name="E-Z Host" description="Connection details for E-Z Host uploads.">
|
||||
<SettingTextInput
|
||||
name="E-Z Host API Key"
|
||||
description="Your E-Z Host API key"
|
||||
value={store.ezHostKey}
|
||||
onChange={v => store.ezHostKey = v}
|
||||
placeholder="Your E-Z Host API key"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isNest && (
|
||||
<SettingTextInput
|
||||
name="Nest Token"
|
||||
description="Your Nest API authorization token"
|
||||
value={store.nestToken}
|
||||
onChange={v => store.nestToken = v}
|
||||
placeholder="Your Nest API token"
|
||||
/>
|
||||
<SettingGroup name="Nest" description="Connection details for Nest uploads.">
|
||||
<SettingTextInput
|
||||
name="Nest Token"
|
||||
description="Your Nest API authorization token"
|
||||
value={store.nestToken}
|
||||
onChange={v => store.nestToken = v}
|
||||
placeholder="Your Nest API token"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isS3 && (
|
||||
<>
|
||||
<SettingGroup name="S3-Compatible Storage" description="Connection details and object naming for your bucket.">
|
||||
<SettingTextInput
|
||||
name="S3 Endpoint URL"
|
||||
description="S3-compatible endpoint (e.g. https://<accountid>.r2.cloudflarestorage.com)"
|
||||
|
|
@ -530,23 +585,25 @@ export function SettingsComponent() {
|
|||
onChange={v => store.s3Prefix = v}
|
||||
placeholder="uploads/discord"
|
||||
/>
|
||||
<SettingsSection tag="label" name="Use Path-Style Endpoint" description="Use endpoint/bucket/key format (recommended for R2)" inlineSetting>
|
||||
<Switch
|
||||
checked={store.s3ForcePathStyle}
|
||||
onChange={v => store.s3ForcePathStyle = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
<SettingSwitch
|
||||
name="Use Path-Style Endpoint"
|
||||
description="Use endpoint/bucket/key format, recommended for R2."
|
||||
checked={store.s3ForcePathStyle}
|
||||
onChange={v => store.s3ForcePathStyle = v}
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isCatbox && (
|
||||
<SettingTextInput
|
||||
name="Catbox Userhash"
|
||||
description="Your Catbox userhash for account binding (leave empty for anonymous uploads)"
|
||||
value={store.catboxUserhash}
|
||||
onChange={v => store.catboxUserhash = v}
|
||||
placeholder="Your Catbox userhash"
|
||||
/>
|
||||
<SettingGroup name="Catbox" description="Optional account binding for Catbox uploads.">
|
||||
<SettingTextInput
|
||||
name="Catbox Userhash"
|
||||
description="Your Catbox userhash for account binding, leave empty for anonymous uploads."
|
||||
value={store.catboxUserhash}
|
||||
onChange={v => store.catboxUserhash = v}
|
||||
placeholder="Your Catbox userhash"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isLitterbox && (
|
||||
|
|
@ -565,37 +622,43 @@ export function SettingsComponent() {
|
|||
)}
|
||||
|
||||
{isGofile && (
|
||||
<SettingTextInput
|
||||
name="GoFile Token"
|
||||
description="Optional GoFile token to upload into your account"
|
||||
value={store.gofileToken}
|
||||
onChange={v => store.gofileToken = v}
|
||||
placeholder="Optional GoFile token"
|
||||
/>
|
||||
<SettingGroup name="GoFile" description="Optional account binding for GoFile uploads.">
|
||||
<SettingTextInput
|
||||
name="GoFile Token"
|
||||
description="Optional GoFile token to upload into your account."
|
||||
value={store.gofileToken}
|
||||
onChange={v => store.gofileToken = v}
|
||||
placeholder="Optional GoFile token"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isPixelVault && (
|
||||
<SettingTextInput
|
||||
name="PixelVault Upload Key"
|
||||
description="Your PixelVault authorization key"
|
||||
value={store.pixelVaultKey}
|
||||
onChange={v => store.pixelVaultKey = v}
|
||||
placeholder="Your PixelVault upload key"
|
||||
/>
|
||||
<SettingGroup name="PixelVault" description="Connection details for PixelVault uploads.">
|
||||
<SettingTextInput
|
||||
name="PixelVault Upload Key"
|
||||
description="Your PixelVault authorization key."
|
||||
value={store.pixelVaultKey}
|
||||
onChange={v => store.pixelVaultKey = v}
|
||||
placeholder="Your PixelVault upload key"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isPixelDrain && (
|
||||
<SettingTextInput
|
||||
name="PixelDrain API Key"
|
||||
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
|
||||
value={store.pixelDrainKey}
|
||||
onChange={v => store.pixelDrainKey = v}
|
||||
placeholder="Your PixelDrain API key"
|
||||
/>
|
||||
<SettingGroup name="PixelDrain" description="Optional account binding for PixelDrain uploads.">
|
||||
<SettingTextInput
|
||||
name="PixelDrain API Key"
|
||||
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
|
||||
value={store.pixelDrainKey}
|
||||
onChange={v => store.pixelDrainKey = v}
|
||||
placeholder="Your PixelDrain API key"
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
{isShareX && (
|
||||
<>
|
||||
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
|
||||
<SettingsSection
|
||||
name="ShareX Custom Uploader Config"
|
||||
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
|
||||
|
|
@ -608,7 +671,7 @@ export function SettingsComponent() {
|
|||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<div className={cl("actions")}>
|
||||
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
|
||||
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
|
||||
</div>
|
||||
|
|
@ -620,134 +683,140 @@ export function SettingsComponent() {
|
|||
onChange={handleShareXFileUpload}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
|
||||
<Switch
|
||||
<SettingGroup name="Upload Behavior" description="Control what FileUpload does after a host returns a URL.">
|
||||
<SettingSwitch
|
||||
name="Strip Query Parameters"
|
||||
description="Strip query parameters from the uploaded file URL."
|
||||
checked={store.stripQueryParams}
|
||||
onChange={v => store.stripQueryParams = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Use Embed Proxy" description="Wrap uploaded video links with an embed proxy service for better Discord previews" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Use Embed Proxy"
|
||||
description="Wrap uploaded video links with an embed proxy service for better Discord previews."
|
||||
checked={store.embedProxyEnabled}
|
||||
onChange={v => {
|
||||
store.embedProxyEnabled = v;
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{store.embedProxyEnabled && (
|
||||
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
|
||||
<Select
|
||||
options={embedProxyOptions}
|
||||
isSelected={v => v === store.embedProxyService}
|
||||
select={v => {
|
||||
store.embedProxyService = v;
|
||||
update();
|
||||
}}
|
||||
serialize={v => v}
|
||||
placeholder="Select an embed proxy service"
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{store.embedProxyEnabled && (
|
||||
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
|
||||
<Select
|
||||
options={embedProxyOptions}
|
||||
isSelected={v => v === store.embedProxyService}
|
||||
select={v => {
|
||||
store.embedProxyService = v;
|
||||
update();
|
||||
}}
|
||||
serialize={v => v}
|
||||
placeholder="Select an embed proxy service"
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingTextInput
|
||||
name="CORS Proxy URL"
|
||||
description="CORS proxy used for web uploads. Leave empty to use the default proxy"
|
||||
value={store.corsProxyUrl || ""}
|
||||
onChange={v => store.corsProxyUrl = v}
|
||||
placeholder="https://your-cors-proxy.example.com"
|
||||
/>
|
||||
|
||||
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
|
||||
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Convert APNG to GIF" description="Convert APNG files to GIF format" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Convert APNG to GIF"
|
||||
description="Convert APNG files to GIF format."
|
||||
checked={store.apngToGif}
|
||||
onChange={v => store.apngToGif = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Preserve Original Filename" description="Use the original filename instead of naming uploads as upload.ext" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Preserve Original Filename"
|
||||
description="Use the original filename instead of naming uploads as upload.ext."
|
||||
checked={Boolean((store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename)}
|
||||
onChange={v => (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Auto Copy URL" description="Automatically copy the uploaded file URL to clipboard" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Auto Copy URL"
|
||||
description="Automatically copy the uploaded file URL to clipboard."
|
||||
checked={store.autoCopy}
|
||||
onChange={v => store.autoCopy = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Disable Fallback Uploaders" description="Only use the selected uploader without trying fallback hosts" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Disable Fallback Uploaders"
|
||||
description="Only use the selected uploader without trying fallback hosts."
|
||||
checked={store.disableFallbacks}
|
||||
onChange={v => store.disableFallbacks = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Insert URL into Chat Input" description="After upload, insert the resulting URL into the current chat input" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Insert URL into Chat Input"
|
||||
description="After upload, insert the resulting URL into the current chat input."
|
||||
checked={store.autoSend}
|
||||
onChange={v => store.autoSend = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Format Inserted URL" description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Format Inserted URL"
|
||||
description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding."
|
||||
checked={store.autoFormat}
|
||||
onChange={v => store.autoFormat = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingsSection tag="label" name="Bypass Discord Upload Button" description="Use FileUpload when uploading through Discord's file picker" inlineSetting>
|
||||
<Switch
|
||||
<SettingGroup name="Discord Integration" description="Choose when FileUpload takes over Discord file handling.">
|
||||
<SettingSwitch
|
||||
name="Bypass Discord Upload Button"
|
||||
description="Use FileUpload when uploading through Discord's file picker."
|
||||
checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
|
||||
onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Auto Upload Pasted Files" description="Automatically upload files from clipboard to image host when pasting in chat input" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Auto Upload Pasted Files"
|
||||
description="Automatically upload files from clipboard to image host when pasting in chat input."
|
||||
checked={store.autoUploadPastedFiles}
|
||||
onChange={v => store.autoUploadPastedFiles = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection tag="label" name="Respect Discord File Size Limit" description="Use FileUpload only for files larger than your current Discord upload limit" inlineSetting>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
name="Respect Discord File Size Limit"
|
||||
description="Use FileUpload only for files larger than your current Discord upload limit."
|
||||
checked={store.bypassDiscordUploadOnlyOverLimit}
|
||||
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "30 seconds", value: 30000 },
|
||||
{ label: "1 minute", value: 60000 },
|
||||
{ label: "2 minutes", value: 120000 },
|
||||
{ label: "5 minutes", value: 300000, default: true },
|
||||
{ label: "10 minutes", value: 600000 }
|
||||
]}
|
||||
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
|
||||
select={v => {
|
||||
store.uploadTimeoutMs = v;
|
||||
update();
|
||||
}}
|
||||
serialize={v => v}
|
||||
placeholder="Select timeout"
|
||||
<SettingGroup name="Network" description="Configure browser upload proxying and timeouts.">
|
||||
<SettingTextInput
|
||||
name="CORS Proxy URL"
|
||||
description="CORS proxy used for web uploads. Leave empty to use the default proxy."
|
||||
value={store.corsProxyUrl || ""}
|
||||
onChange={v => store.corsProxyUrl = v}
|
||||
placeholder="https://your-cors-proxy.example.com"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
|
||||
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "30 seconds", value: 30000 },
|
||||
{ label: "1 minute", value: 60000 },
|
||||
{ label: "2 minutes", value: 120000 },
|
||||
{ label: "5 minutes", value: 300000, default: true },
|
||||
{ label: "10 minutes", value: 600000 }
|
||||
]}
|
||||
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
|
||||
select={v => {
|
||||
store.uploadTimeoutMs = v;
|
||||
update();
|
||||
}}
|
||||
serialize={v => v}
|
||||
placeholder="Select timeout"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingGroup>
|
||||
|
||||
<FallbackOrderSettings />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vc-file-upload-progress-percent {
|
||||
font-size: 12px;
|
||||
color: var(--text-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vc-file-upload-progress-cancel {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
|
|
@ -106,3 +112,45 @@
|
|||
.vc-file-upload-settings-fallback-order-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-service-option {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-default);
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-service-option:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-service-option[data-selected="true"] {
|
||||
border-color: var(--brand-500);
|
||||
background: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-service-option-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vc-file-upload-settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ type S3Store = {
|
|||
corsProxyUrl?: string;
|
||||
};
|
||||
|
||||
type UploadRequestResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
};
|
||||
|
||||
type UploadRequest = (url: string, options: RequestInit) => Promise<UploadRequestResponse>;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function toArrayBuffer(data: ArrayBuffer | Uint8Array | string): ArrayBuffer {
|
||||
|
|
@ -90,7 +98,8 @@ export function isS3Configured(): boolean {
|
|||
export async function uploadToS3(
|
||||
fileBlob: Blob,
|
||||
filename: string,
|
||||
native: PluginNative<typeof import("../native")> | null
|
||||
native: PluginNative<typeof import("../native")> | null,
|
||||
uploadRequest: UploadRequest
|
||||
): Promise<string> {
|
||||
const {
|
||||
s3Endpoint,
|
||||
|
|
@ -189,7 +198,7 @@ export async function uploadToS3(
|
|||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
|
||||
const response = await uploadRequest(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
|
||||
method: "PUT",
|
||||
headers: requestHeaders,
|
||||
body: fileBlob
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface UploadProgressState {
|
|||
attempt: number;
|
||||
totalAttempts: number;
|
||||
percent: number;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
status: string;
|
||||
canCancel: boolean;
|
||||
}
|
||||
|
|
@ -60,6 +62,8 @@ const defaultUploadState: UploadProgressState = {
|
|||
attempt: 0,
|
||||
totalAttempts: 0,
|
||||
percent: 0,
|
||||
transferredBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: "",
|
||||
canCancel: false
|
||||
};
|
||||
|
|
@ -67,6 +71,7 @@ const defaultUploadState: UploadProgressState = {
|
|||
let uploadState: UploadProgressState = { ...defaultUploadState };
|
||||
const uploadStateListeners = new Set<() => void>();
|
||||
let activeAbortController: AbortController | null = null;
|
||||
let activeXhr: XMLHttpRequest | null = null;
|
||||
let cancelRequested = false;
|
||||
|
||||
function isUploadCancelledError(error: unknown): boolean {
|
||||
|
|
@ -121,6 +126,7 @@ export function cancelCurrentUpload() {
|
|||
|
||||
cancelRequested = true;
|
||||
activeAbortController?.abort();
|
||||
activeXhr?.abort();
|
||||
setUploadState({
|
||||
phase: "cancelled",
|
||||
status: "Upload cancelled.",
|
||||
|
|
@ -163,6 +169,103 @@ async function fetchWithTimeout(url: string, options: RequestInit): Promise<Resp
|
|||
}
|
||||
}
|
||||
|
||||
function getHeaderEntries(headers?: HeadersInit): [string, string][] {
|
||||
if (!headers) return [];
|
||||
if (headers instanceof Headers) return Array.from(headers.entries());
|
||||
if (Array.isArray(headers)) return headers.map(([key, value]) => [key, value]);
|
||||
|
||||
return Object.entries(headers);
|
||||
}
|
||||
|
||||
class XhrResponse {
|
||||
ok: boolean;
|
||||
headers: Headers;
|
||||
status: number;
|
||||
statusText: string;
|
||||
url: string;
|
||||
|
||||
constructor(private xhr: XMLHttpRequest) {
|
||||
this.status = xhr.status;
|
||||
this.statusText = xhr.statusText;
|
||||
this.url = xhr.responseURL;
|
||||
this.ok = this.status >= 200 && this.status < 300;
|
||||
this.headers = new Headers();
|
||||
|
||||
const rawHeaders = xhr.getAllResponseHeaders();
|
||||
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
|
||||
if (!line) continue;
|
||||
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex < 0) continue;
|
||||
|
||||
this.headers.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return typeof this.xhr.response === "string"
|
||||
? this.xhr.response
|
||||
: this.xhr.responseText;
|
||||
}
|
||||
|
||||
async json(): Promise<unknown> {
|
||||
return JSON.parse(await this.text());
|
||||
}
|
||||
}
|
||||
|
||||
function setXhrUploadProgress(event: ProgressEvent) {
|
||||
if (!event.lengthComputable || event.total <= 0) {
|
||||
setUploadState({
|
||||
status: uploadState.currentServiceLabel
|
||||
? `Uploading via ${uploadState.currentServiceLabel}...`
|
||||
: "Uploading..."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = Math.round(Math.max(0, Math.min(100, event.loaded / event.total * 100)));
|
||||
setUploadState({
|
||||
phase: "uploading",
|
||||
percent,
|
||||
transferredBytes: event.loaded,
|
||||
totalBytes: event.total,
|
||||
status: uploadState.currentServiceLabel
|
||||
? `Uploading via ${uploadState.currentServiceLabel}...`
|
||||
: "Uploading..."
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadRequestWithTimeout(url: string, options: RequestInit): Promise<XhrResponse> {
|
||||
const requestUrl = toProxyUrl(url);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
activeXhr = xhr;
|
||||
const timeout = setTimeout(() => xhr.abort(), getUploadTimeoutMs());
|
||||
|
||||
xhr.open(options.method || "GET", requestUrl);
|
||||
|
||||
for (const [key, value] of getHeaderEntries(options.headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = setXhrUploadProgress;
|
||||
xhr.onload = () => resolve(new XhrResponse(xhr));
|
||||
xhr.onerror = () => reject(new Error("Upload failed"));
|
||||
xhr.onabort = () => reject(new Error(cancelRequested ? "Upload cancelled by user" : "Upload timed out"));
|
||||
xhr.onloadend = () => {
|
||||
clearTimeout(timeout);
|
||||
xhr.upload.onprogress = null;
|
||||
if (activeXhr === xhr) {
|
||||
activeXhr = null;
|
||||
}
|
||||
};
|
||||
|
||||
const { body } = options;
|
||||
xhr.send(body instanceof ReadableStream ? null : body as XMLHttpRequestBodyInit | null);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveShareXRequestValue(value: string | number | boolean, filename: string): string {
|
||||
return String(value)
|
||||
.replace(/\$filename\$/g, filename)
|
||||
|
|
@ -225,7 +328,7 @@ async function uploadToShareX(fileBlob: Blob, filename: string): Promise<string>
|
|||
throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`);
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(requestUrl, { method, headers, body });
|
||||
const response = await uploadRequestWithTimeout(requestUrl, { method, headers, body });
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseJson: unknown = null;
|
||||
|
|
@ -272,7 +375,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
|
|||
headers["x-zipline-folder"] = folderId;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(`${baseUrl}/api/upload`, {
|
||||
const response = await uploadRequestWithTimeout(`${baseUrl}/api/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData
|
||||
|
|
@ -289,7 +392,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
|
|||
throw new Error("Server returned invalid response (not JSON)");
|
||||
}
|
||||
|
||||
const data: UploadResponse = await response.json();
|
||||
const data = await response.json() as UploadResponse;
|
||||
|
||||
if (data.files && data.files.length > 0 && data.files[0].url) {
|
||||
return data.files[0].url;
|
||||
|
|
@ -323,7 +426,7 @@ async function uploadToNest(fileBlob: Blob, filename: string): Promise<string> {
|
|||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const response = await fetchWithTimeout("https://nest.rip/api/files/upload", {
|
||||
const response = await uploadRequestWithTimeout("https://nest.rip/api/files/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": nestToken
|
||||
|
|
@ -416,7 +519,7 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
|
|||
|
||||
const headers: Record<string, string> = { key: ezHostKey };
|
||||
|
||||
const response = await fetchWithTimeout("https://api.e-z.host/files", {
|
||||
const response = await uploadRequestWithTimeout("https://api.e-z.host/files", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData
|
||||
|
|
@ -427,12 +530,14 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
|
|||
throw new Error(`Upload failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json() as { success?: boolean; error?: string; imageUrl?: string; rawUrl?: string; };
|
||||
if (!data || !data.success) {
|
||||
throw new Error(data?.error || "Upload failed");
|
||||
}
|
||||
|
||||
return data.imageUrl || data.rawUrl;
|
||||
const url = data.imageUrl || data.rawUrl;
|
||||
if (!url) throw new Error("No URL returned from upload");
|
||||
return url;
|
||||
}
|
||||
|
||||
async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string> {
|
||||
|
|
@ -449,7 +554,7 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
|
|||
if (catboxUserhash) formData.append("userhash", catboxUserhash);
|
||||
formData.append("fileToUpload", fileBlob, filename);
|
||||
|
||||
const response = await fetchWithTimeout("https://catbox.moe/user/api.php", {
|
||||
const response = await uploadRequestWithTimeout("https://catbox.moe/user/api.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -493,7 +598,7 @@ async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<stri
|
|||
formData.append("time", expiry);
|
||||
formData.append("fileToUpload", fileBlob, filename);
|
||||
|
||||
const response = await fetchWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
|
||||
const response = await uploadRequestWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -520,7 +625,7 @@ async function uploadToGofile(fileBlob: Blob, filename: string): Promise<string>
|
|||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const uploadUrl = "https://upload.gofile.io/uploadfile";
|
||||
const response = await fetchWithTimeout(uploadUrl, {
|
||||
const response = await uploadRequestWithTimeout(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -555,7 +660,7 @@ async function uploadToTmpfiles(fileBlob: Blob, filename: string): Promise<strin
|
|||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const uploadUrl = "https://tmpfiles.org/api/v1/upload";
|
||||
const response = await fetchWithTimeout(uploadUrl, {
|
||||
const response = await uploadRequestWithTimeout(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -583,7 +688,7 @@ async function uploadToBuzzheavier(fileBlob: Blob, filename: string): Promise<st
|
|||
}
|
||||
|
||||
const uploadUrl = `https://w.buzzheavier.com/${encodeURIComponent(filename)}`;
|
||||
const response = await fetchWithTimeout(uploadUrl, {
|
||||
const response = await uploadRequestWithTimeout(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: fileBlob
|
||||
});
|
||||
|
|
@ -618,7 +723,7 @@ async function uploadToTempSh(fileBlob: Blob, filename: string): Promise<string>
|
|||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const uploadUrl = "https://temp.sh/upload";
|
||||
const response = await fetchWithTimeout(uploadUrl, {
|
||||
const response = await uploadRequestWithTimeout(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -650,7 +755,7 @@ async function uploadToFilebin(fileBlob: Blob, filename: string): Promise<string
|
|||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const response = await fetchWithTimeout(uploadUrl, {
|
||||
const response = await uploadRequestWithTimeout(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -686,7 +791,7 @@ async function uploadToPixelVault(fileBlob: Blob, filename: string): Promise<str
|
|||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
|
||||
const response = await fetchWithTimeout("https://pixelvault.co/", {
|
||||
const response = await uploadRequestWithTimeout("https://pixelvault.co/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: pixelVaultKey.trim()
|
||||
|
|
@ -729,7 +834,7 @@ async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise<str
|
|||
headers.Authorization = `Basic ${btoa(`:${pixelDrainKey.trim()}`)}`;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
|
||||
const response = await uploadRequestWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: fileBlob
|
||||
|
|
@ -763,7 +868,7 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
|
|||
case ServiceType.EZHOST:
|
||||
return uploadToEzHost(fileBlob, filename);
|
||||
case ServiceType.S3:
|
||||
return uploadToS3(fileBlob, filename, Native);
|
||||
return uploadToS3(fileBlob, filename, Native, uploadRequestWithTimeout);
|
||||
case ServiceType.CATBOX:
|
||||
return uploadToCatbox(fileBlob, filename);
|
||||
case ServiceType.ZEROX0:
|
||||
|
|
@ -940,6 +1045,8 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
|
|||
totalAttempts: uploadOrder.length,
|
||||
attempt: 1,
|
||||
percent: 5,
|
||||
transferredBytes: 0,
|
||||
totalBytes: fileBlob.size,
|
||||
status: `Starting upload via ${serviceLabels[uploadOrder[0]]}...`,
|
||||
currentService: uploadOrder[0],
|
||||
currentServiceLabel: serviceLabels[uploadOrder[0]],
|
||||
|
|
@ -950,12 +1057,15 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
|
|||
if (cancelRequested) throw new Error("Upload cancelled by user");
|
||||
|
||||
const attempt = attempted.length + 1;
|
||||
|
||||
setUploadState({
|
||||
phase: attempt === 1 ? "uploading" : "retrying",
|
||||
attempt,
|
||||
currentService: service,
|
||||
currentServiceLabel: serviceLabels[service],
|
||||
percent: Math.min(90, 10 + Math.round((attempt / uploadOrder.length) * 70)),
|
||||
transferredBytes: 0,
|
||||
totalBytes: fileBlob.size,
|
||||
percent: 0,
|
||||
status: attempt === 1
|
||||
? `Uploading via ${serviceLabels[service]}...`
|
||||
: `Retrying with ${serviceLabels[service]} (${attempt}/${uploadOrder.length})...`
|
||||
|
|
@ -1134,6 +1244,7 @@ export async function uploadFile(url: string): Promise<void> {
|
|||
} finally {
|
||||
isUploading = false;
|
||||
activeAbortController = null;
|
||||
activeXhr = null;
|
||||
setTimeout(() => resetUploadState(), 1800);
|
||||
}
|
||||
}
|
||||
|
|
@ -1197,6 +1308,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise<void>
|
|||
} finally {
|
||||
isUploading = false;
|
||||
activeAbortController = null;
|
||||
activeXhr = null;
|
||||
setTimeout(() => resetUploadState(), 1800);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import * as DataStore from "@api/DataStore";
|
|||
|
||||
import { removeRecentStickerByPackId } from "./components";
|
||||
import { DynamicPackSetMeta, DynamicStickerPackMeta, StickerPack, StickerPackMeta } from "./types";
|
||||
import { Mutex } from "./utils";
|
||||
import { corsFetch } from "./utils";
|
||||
import { corsFetch, Mutex } from "./utils";
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue