Add folder uploads, add settings description, add metrics increase/decrease percentage

This commit is contained in:
Stef-00012 2025-03-29 13:39:24 +01:00
parent 274343a5cf
commit 27edb6730f
No known key found for this signature in database
GPG key ID: 28BE9A9E4EF0E6BF
30 changed files with 1270 additions and 225 deletions

View file

@ -35,6 +35,7 @@ This will create an apk but won't automatically install
# TODO
- [ ] Add folder upload + upload button in folder view
- [ ] Add description to settings like Zipline does
- [ ] Add upload policy to folder large view
- [x] Add folder upload + upload button in folder view
- [x] Add description to settings like Zipline does
- [x] Add upload policy to folder large view
- [x] Add increase/decrease percetage in metrics

View file

@ -106,6 +106,8 @@ export default function Files() {
const url = db.get("url") as DashURL | null;
const [name, setName] = useState<string | null>(null);
const [allowUploads, setAllowUploads] = useState<boolean>(false);
const [folderId, setFolderId] = useState<string | null>(null);
const [isFolder, setIsFolder] = useState<boolean>(false);
const [focusedFile, setFocusedFile] = useState<APIFile | null>(null);
@ -153,6 +155,8 @@ export default function Files() {
if (typeof folder === "string") return router.replace("/+not-found");
setName(folder.name);
setAllowUploads(folder.allowUploads);
setFolderId(folder.id);
setIsFolder(true);
return setFiles({
@ -566,49 +570,55 @@ export default function Files() {
/>
{!name && (
<>
<Button
onPress={() => {
setTagsMenuOpen(true);
}}
icon="sell"
color="transparent"
iconColor={files ? "#2d3f70" : "#2d3f7055"}
borderColor="#222c47"
borderWidth={2}
iconSize={30}
disabled={!files}
padding={4}
rippleColor="#283557"
margin={{
left: 2,
right: 2,
}}
/>
<Button
onPress={() => {
router.replace("/upload/file");
}}
icon="upload-file"
color="transparent"
iconColor={files ? "#2d3f70" : "#2d3f7055"}
borderColor="#222c47"
borderWidth={2}
iconSize={30}
disabled={!files}
padding={4}
rippleColor="#283557"
margin={{
left: 2,
right: 2,
}}
/>
</>
<Button
onPress={() => {
setTagsMenuOpen(true);
}}
icon="sell"
color="transparent"
iconColor={files ? "#2d3f70" : "#2d3f7055"}
borderColor="#222c47"
borderWidth={2}
iconSize={30}
disabled={!files}
padding={4}
rippleColor="#283557"
margin={{
left: 2,
right: 2,
}}
/>
)}
</>
)}
<Button
onPress={() => {
const url = isFolder
? (`/folders/upload?folderId=${folderId}` as `/folders/upload?folderId=${string | null}`)
: "/upload/file";
router.replace(url);
}}
icon="upload-file"
color="transparent"
iconColor={
(files && !isFolder) || (isFolder && allowUploads)
? "#2d3f70"
: "#2d3f7055"
}
borderColor="#222c47"
borderWidth={2}
iconSize={30}
disabled={!files || (isFolder && !allowUploads)}
padding={4}
rippleColor="#283557"
margin={{
left: 2,
right: 2,
}}
/>
<Button
onPress={() => {
db.set(

View file

@ -42,7 +42,7 @@ export default function ServerSettings() {
}, []);
const [saveSettings, setSaveSettings] = useState<SaveSettings | null>(null);
const [saving, setSaving] = useState<boolean>(false)
const [saving, setSaving] = useState<boolean>(false);
useEffect(() => {
if (settings) {
@ -223,7 +223,7 @@ export default function ServerSettings() {
async function handleSave(category: SaveCategories) {
setSaveError(null);
setSaving(true)
setSaving(true);
if (!saveSettings) return setSaving(false);
let settingsToSave: Partial<APISettings> = {};
@ -463,30 +463,30 @@ export default function ServerSettings() {
break;
}
}
if (Object.keys(settingsToSave).length <= 0) {
setSaveError(["Something went wrong..."])
return setSaving(false)
setSaveError(["Something went wrong..."]);
return setSaving(false);
}
const success = await updateSettings(settingsToSave);
if (Array.isArray(success)) {
setSaveError(success);
setSaving(false)
setSaving(false);
}
const reloadSuccess = await reloadSettings();
if (typeof reloadSuccess === "string") {
setSaveError([`Error while reloading: ${reloadSuccess}`]);
setSaving(false)
setSaving(false);
}
const newSettings = await getSettings();
setSettings(typeof newSettings === "string" ? null : newSettings);
setSaving(false)
setSaving(false);
return ToastAndroid.show(
"Successfully saved the settings",
@ -522,6 +522,12 @@ export default function ServerSettings() {
>
<Text style={styles.headerText}>{setting.name}</Text>
{setting.description && (
<Text style={styles.headerDescription}>
{setting.description}
</Text>
)}
{setting.children.map((childSetting) =>
renderSetting(childSetting),
)}
@ -534,6 +540,7 @@ export default function ServerSettings() {
<TextInput
key={setting.setting}
title={setting.name}
description={setting.description}
password={setting.passwordInput}
disabled={saving}
keyboardType={setting.keyboardType}
@ -579,6 +586,7 @@ export default function ServerSettings() {
title={setting.name}
key={setting.setting}
data={setting.options}
description={setting.description}
disabled={saving}
onSelect={(selectedItem) => {
setSaveSettings((prev) => {
@ -601,6 +609,7 @@ export default function ServerSettings() {
<Switch
key={setting.setting}
disabled={saving}
description={setting.description}
onValueChange={() =>
setSaveSettings((prev) => {
return {
@ -625,7 +634,12 @@ export default function ServerSettings() {
justifyContent: "space-between",
}}
>
<Text style={styles.externalLinkTitle}>External Links</Text>
<View>
<Text style={styles.externalLinkTitle}>External Links</Text>
<Text style={styles.externalLinkDescription}>
The external links to show in the footer.
</Text>
</View>
<Button
color="#323ea8"
@ -643,6 +657,7 @@ export default function ServerSettings() {
}}
/>
</View>
<View
style={{
...styles.settingGroup,
@ -739,6 +754,7 @@ export default function ServerSettings() {
return (
<ColorPicker
title={setting.name}
description={setting.description}
disabled={saving}
initialColor={saveSettings?.[setting.setting] as string | undefined}
onSelectColor={(color) => {
@ -774,15 +790,15 @@ export default function ServerSettings() {
}
useEffect(() => {
console.log(saveError, !saveError || saveError.length <= 0)
}, [saveError])
console.log(saveError, !saveError || saveError.length <= 0);
}, [saveError]);
return (
<View style={styles.mainContainer}>
<View style={styles.mainContainer}>
<Popup
onClose={() => {
setSaveError(null)
setSaveError(null);
}}
hidden={!saveError || saveError.length <= 0}
>
@ -791,7 +807,9 @@ export default function ServerSettings() {
<ScrollView style={styles.popupScrollView}>
{saveError?.map((error) => (
<Text key={error} style={styles.errorText}>{error}</Text>
<Text key={error} style={styles.errorText}>
{error}
</Text>
))}
</ScrollView>
@ -801,9 +819,6 @@ export default function ServerSettings() {
</View>
</Popup>
{/* <Popup
onClose={() => {
setSaveError(null)

View file

@ -25,6 +25,7 @@ import {
export type FolderActions =
| "viewFiles"
| "visibility"
| "uploadPolicy"
| "edit"
| "copyUrl"
| "delete";
@ -66,7 +67,14 @@ export default function Folders() {
const dashUrl = db.get("url") as DashURL | null;
const [sortKey, setSortKey] = useState<{
id: "name" | "public" | "allowUploads" | "createdAt" | "updatedAt" | "files" | "id";
id:
| "name"
| "public"
| "allowUploads"
| "createdAt"
| "updatedAt"
| "files"
| "id";
sortOrder: "asc" | "desc";
}>({
id: "createdAt",
@ -133,8 +141,8 @@ export default function Folders() {
ToastAndroid.SHORT,
);
}
case "uploasPolicy": {
case "uploadPolicy": {
const folderId = folder.id;
const success = await editFolder(folderId, {
@ -413,9 +421,9 @@ export default function Folders() {
sortable: true,
},
{
row: "Uploads?",
id: "allowUploads",
sortable: true,
row: "Uploads?",
id: "allowUploads",
sortable: true,
},
{
row: "Created",
@ -524,7 +532,7 @@ export default function Folders() {
{folder.public ? "Yes" : "No"}
</Text>
);
const allowUploads = (
<Text key={folder.id} style={styles.rowText}>
{folder.allowUploads ? "Yes" : "No"}
@ -600,16 +608,18 @@ export default function Folders() {
onAction("visibility", folder);
}}
/>
<Button
icon={folder.allowUploads ? "block" : "share"}
color={folder.allowUploads ? "#323ea8" : "#343a40"}
color={
folder.allowUploads ? "#323ea8" : "#343a40"
}
iconSize={20}
width={32}
height={32}
padding={6}
onPress={async () => {
onAction("uploasPolicy", folder);
onAction("uploadPolicy", folder);
}}
/>

View file

@ -1,22 +1,540 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { getFolder, getFolders } from "@/functions/zipline/folders";
import {
type ExternalPathString,
Link,
useLocalSearchParams,
useRouter,
} from "expo-router";
import { getFolder } from "@/functions/zipline/folders";
import { ScrollView, Text, ToastAndroid, View } from "react-native";
import { useEffect, useState } from "react";
import type { APIFolder, APIUploadResponse, DashURL } from "@/types/zipline";
import type { SelectedFile } from "@/app/(app)/(files)/upload/file";
import { uploadFiles, type UploadFileOptions } from "@/functions/zipline/files";
import { getSettings } from "@/functions/zipline/settings";
import { useDetectKeyboardOpen } from "@/hooks/isKeyboardOpen";
import * as DocumentPicker from "expo-document-picker";
import Popup from "@/components/Popup";
import * as Clipboard from "expo-clipboard";
import * as FileSystem from "expo-file-system";
import Button from "@/components/Button";
import FileDisplay from "@/components/FileDisplay";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import Select from "@/components/Select";
import { dates, formats } from "@/constants/upload";
import TextInput from "@/components/TextInput";
import Switch from "@/components/Switch";
import { guessExtension } from "@/functions/util";
import type { Mimetypes } from "@/types/mimetypes";
import { styles } from "@/styles/folders/upload";
import * as db from "@/functions/database";
export default function FolderUpload() {
const searchParams = useLocalSearchParams<{
folderId?: string;
}>();
const [folder, setFolder] = useState<APIFolder>(null)
useEffect(() => {
(async () => {
const folder = await getFolder(searchParams.folderId)
setFolder(typeof folder === "string" ? null : folder)
})
})
return (
)
}
const router = useRouter();
const searchParams = useLocalSearchParams<{
folderId?: string;
}>();
const url = db.get("url") as DashURL | null;
const [folder, setFolder] = useState<APIFolder | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Array<SelectedFile>>([]);
const [uploadedFiles, setUploadedFiles] = useState<
APIUploadResponse["files"]
>([]);
const [failedUploads, setFailedUploads] = useState<
Array<{
uri: string;
name: string;
error: string;
}>
>([]);
const [overrideDomain, setOverrideDomain] =
useState<UploadFileOptions["overrideDomain"]>();
const [originalName, setOriginalName] =
useState<UploadFileOptions["originalName"]>(false);
const [compression, setCompression] =
useState<UploadFileOptions["compression"]>();
const [deletesAt, setDeletesAt] = useState<UploadFileOptions["expiresAt"]>();
const [nameFormat, setNameFormat] = useState<UploadFileOptions["format"]>();
const [maxViews, setMaxViews] = useState<UploadFileOptions["maxViews"]>();
const [fileName, setFileName] = useState<UploadFileOptions["filename"]>();
const [password, setPassword] = useState<UploadFileOptions["password"]>();
const [defaultFormat, setDefaultFormat] = useState<string>("random");
const [uploadButtonDisabled, setUploadButtonDisabled] =
useState<boolean>(true);
const [fileNameEnabled, setFileNameEnabled] = useState<boolean>(true);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadPercentage, setUploadPercentage] = useState<string>("0");
// biome-ignore lint/correctness/useExhaustiveDependencies: .
useEffect(() => {
(async () => {
if (!searchParams.folderId) return router.replace("/+not-found");
const folder = await getFolder(searchParams.folderId);
if (typeof folder === "string") return router.replace("/+not-found");
if (!folder.allowUploads) return router.replace("/+not-found");
setFolder(folder);
const settings = await getSettings();
if (typeof settings !== "string") {
setDefaultFormat(settings.filesDefaultFormat);
}
})();
}, []);
function afterUploadCleanup() {
setUploading(false);
setSelectedFiles([]);
setMaxViews(undefined);
setFileName(undefined);
setPassword(undefined);
setOriginalName(false);
setDeletesAt(undefined);
setNameFormat(undefined);
setCompression(undefined);
setOverrideDomain(undefined);
}
useEffect(() => {
setUploadButtonDisabled(selectedFiles.length === 0);
setFileNameEnabled(selectedFiles.length <= 1);
}, [selectedFiles]);
const iskeyboardOpen = useDetectKeyboardOpen(false);
return (
<View style={styles.mainContainer}>
<Popup
onClose={() => {
setUploadedFiles([]);
setFailedUploads([]);
}}
hidden={!(uploadedFiles.length > 0 || failedUploads.length > 0)}
>
{uploadedFiles.length > 0 && (
<View>
<Text style={styles.headerText}>Uploaded Files</Text>
<ScrollView style={styles.popupScrollView}>
{uploadedFiles.map((file) => (
<View key={file.id} style={styles.uploadedFileContainer}>
<Link
href={file.url as ExternalPathString}
style={styles.uploadedFileUrl}
>
{file.url}
</Link>
<Button
icon="content-copy"
color="#323ea8"
onPress={async () => {
const saved = await Clipboard.setStringAsync(file.url);
if (saved)
return ToastAndroid.show(
"URL copied to clipboard",
ToastAndroid.SHORT,
);
ToastAndroid.show(
"Failed to copy the URL",
ToastAndroid.SHORT,
);
}}
iconSize={20}
width={32}
height={32}
padding={6}
margin={{
left: 5,
right: 5,
}}
/>
<Button
icon="open-in-new"
color="#323ea8"
onPress={() => {
router.replace(file.url as ExternalPathString);
}}
iconSize={20}
width={32}
height={32}
padding={6}
margin={{
left: 5,
right: 5,
}}
/>
</View>
))}
</ScrollView>
</View>
)}
{failedUploads.length > 0 && (
<View
style={{
...(uploadedFiles.length > 0 && {
marginTop: 10,
}),
}}
>
<Text style={styles.headerText}>Failed Files</Text>
<ScrollView style={styles.popupScrollView}>
{failedUploads.map((file) => (
<Text key={file.uri} style={styles.failedFileText}>
<Text style={{ fontWeight: "bold" }}>{file.name}</Text>:{" "}
<Text style={{ color: "red" }}>{file.error}</Text>
</Text>
))}
</ScrollView>
</View>
)}
<Text
style={{
...styles.subHeaderText,
...styles.popupSubHeaderText,
}}
>
Press outside to close this popup
</Text>
</Popup>
<View>
<View style={styles.header}>
<View>
<Text style={styles.headerText}>
Selected Files for {folder?.name}
</Text>
<Text style={styles.subHeaderText}>Click a file to remove it</Text>
</View>
</View>
<ScrollView
horizontal
style={{
...styles.scrollView,
...(iskeyboardOpen && styles.scrollViewKeyboardOpen),
}}
>
{selectedFiles.map((file) => (
<View key={file.uri} style={styles.recentFileContainer}>
<FileDisplay
uri={file.uri}
name={file.name}
width={200}
height={200}
mimetype={file.mimetype}
openable={false}
onPress={() => {
if (uploading) return;
setSelectedFiles((alreadySelectedFiles) =>
alreadySelectedFiles.filter(
(selectedFile) => selectedFile.uri !== file.uri,
),
);
}}
/>
</View>
))}
</ScrollView>
</View>
<View>
<Button
width="90%"
disabled={uploading}
onPress={async () => {
const output = await DocumentPicker.getDocumentAsync({
type: "*/*",
multiple: true,
copyToCacheDirectory: true,
});
if (output.canceled || !output.assets) return;
const newSelectedFiles: Array<SelectedFile> = output.assets
.map((file) => ({
name: file.name,
uri: file.uri,
mimetype: file.mimeType,
size: file.size,
}))
.filter(
(newFile) =>
!selectedFiles.find(
(selectedFile) =>
newFile.size === selectedFile.size &&
newFile.name === selectedFile.name,
),
);
setSelectedFiles((alreadySelectedFiles) => [
...alreadySelectedFiles,
...newSelectedFiles,
]);
}}
text="Select File(s)"
color={uploading ? "#373d79" : "#323ea8"}
textColor={uploading ? "gray" : "white"}
margin={{
left: "auto",
right: "auto",
top: 10,
}}
/>
{folder?.public ? (
<Text style={styles.folderStatusText}>
This folder is{" "}
<Link
style={styles.folderStatusLink}
href={`${url}/folder/${folder?.id}` as ExternalPathString}
>
public
</Link>
. Anyone with the link can view its contents and upload files.
</Text>
) : (
<Text style={styles.folderStatusText}>
Only the owner can view this folder's contents. However, anyone can
upload files, and they can still access their uploaded files if they
have the link to the specific file.
</Text>
)}
</View>
<KeyboardAwareScrollView
keyboardShouldPersistTaps="always"
style={styles.inputsContainer}
showsVerticalScrollIndicator={false}
>
<Text
style={{
...styles.inputHeader,
...(uploading && styles.inputHeaderDisabled),
}}
>
Deletes At:
</Text>
<Select
data={dates}
placeholder="Select Date..."
disabled={uploading}
onSelect={(selectedDate) => {
if (selectedDate.length <= 0) return;
if (selectedDate[0].value === "never")
return setDeletesAt(undefined);
const deletesDate = new Date(
Date.now() + (selectedDate[0].milliseconds as number),
);
setDeletesAt(deletesDate);
}}
/>
<Text
style={{
...styles.inputHeader,
...(uploading && styles.inputHeaderDisabled),
}}
>
Format:
</Text>
<Select
data={[
{
label: `Default (${defaultFormat})`,
value: defaultFormat || "random",
},
...formats,
]}
disabled={uploading}
placeholder="Select Format..."
onSelect={(selectedFormat) => {
if (selectedFormat.length <= 0) return;
setNameFormat(
selectedFormat[0].value as UploadFileOptions["format"],
);
}}
/>
<TextInput
title="Compression:"
onValueChange={(content) => {
let compressionPercentage = Number.parseInt(content);
if (compressionPercentage > 100) compressionPercentage = 100;
if (compressionPercentage < 0) compressionPercentage = 0;
setCompression(compressionPercentage);
}}
keyboardType="numeric"
disableContext={uploading}
disabled={uploading}
value={compression ? String(compression) : ""}
placeholder="0"
/>
<TextInput
title="Max Views:"
onValueChange={(content) => {
let maxViewsAmount = Number.parseInt(content);
if (maxViewsAmount < 0) maxViewsAmount = 0;
setMaxViews(maxViewsAmount);
}}
keyboardType="numeric"
disableContext={uploading}
disabled={uploading}
value={maxViews ? String(maxViews) : ""}
placeholder="0"
/>
<TextInput
title="Override Domain:"
onValueChange={(content) => setOverrideDomain(content)}
keyboardType="url"
disableContext={uploading}
disabled={uploading}
value={overrideDomain || ""}
placeholder="example.com"
/>
<TextInput
title="Override File Name:"
disableContext={fileNameEnabled || !uploading}
disabled={!fileNameEnabled || uploading}
onValueChange={(content) => setFileName(content)}
value={fileNameEnabled ? fileName || "" : ""}
placeholder="example.png"
/>
<TextInput
title="Password:"
onValueChange={(content) => setPassword(content)}
disableContext={uploading}
disabled={uploading}
password
value={password || ""}
placeholder="myPassword"
/>
<Switch
title="Add Original Name"
value={originalName || false}
disabled={uploading}
onValueChange={() => setOriginalName((prev) => !prev)}
/>
</KeyboardAwareScrollView>
<View>
<Button
width="90%"
disabled={uploading || uploadButtonDisabled}
onPress={async () => {
setUploading(true);
const successful = [];
const fails: typeof failedUploads = [];
for (const file of selectedFiles) {
const fileInfo = await FileSystem.getInfoAsync(file.uri, {
size: true,
});
if (!fileInfo.exists || fileInfo.isDirectory) {
fails.push({
error: "File does not exist",
uri: file.uri,
name: file.name,
});
continue;
}
const mimetype =
file.mimetype ||
guessExtension(
file.uri.split(".").pop() as Mimetypes[keyof Mimetypes],
);
const fileData = {
uri: fileInfo.uri || file.uri,
mimetype,
};
const uploadedFile = await uploadFiles(
fileData,
{
compression,
expiresAt: deletesAt,
filename: fileNameEnabled ? fileName : undefined,
folder: searchParams.folderId,
format: nameFormat,
maxViews,
originalName,
overrideDomain,
password,
},
(uploadData) => {
setUploadPercentage(
(
(uploadData.totalBytesSent /
uploadData.totalBytesExpectedToSend) *
100
).toFixed(2),
);
},
);
if (typeof uploadedFile === "string") {
fails.push({
error: uploadedFile,
uri: file.uri,
name: file.name,
});
continue;
}
successful.push(uploadedFile[0]);
}
setUploadedFiles(successful);
setFailedUploads(fails);
afterUploadCleanup();
}}
text={
uploading ? `Uploading... ${uploadPercentage}%` : "Upload File(s)"
}
color={uploading || uploadButtonDisabled ? "#373d79" : "#323ea8"}
textColor={uploading || uploadButtonDisabled ? "gray" : "white"}
margin={{
left: "auto",
right: "auto",
top: 10,
bottom: 10,
}}
/>
</View>
</View>
);
}

View file

@ -1,6 +1,10 @@
import { Dimensions, ScrollView, Text, View } from "react-native";
import { LineChart, PieChart } from "react-native-gifted-charts";
import { colorHash, convertToBytes } from "@/functions/util";
import {
colorHash,
convertToBytes,
getMetricsDifference,
} from "@/functions/util";
import type { DateType } from "react-native-ui-datepicker";
import { getSettings } from "@/functions/zipline/settings";
import { useShareIntent } from "@/hooks/useShareIntent";
@ -19,6 +23,7 @@ import {
getStats,
type StatsProps,
} from "@/functions/zipline/stats";
import { MaterialIcons } from "@expo/vector-icons";
export default function Metrics() {
useAuth();
@ -29,6 +34,21 @@ export default function Metrics() {
useState<boolean>(false);
const [filteredStats, setFilteredStats] = useState<APIStats | null>(null);
const [mainStat, setMainStat] = useState<APIStats[0] | null>(null);
const [statsDifferences, setStatsDifferences] = useState<{
files: number;
urls: number;
storage: number;
users: number;
fileViews: number;
urlViews: number;
}>({
files: 0,
fileViews: 0,
storage: 0,
urls: 0,
urlViews: 0,
users: 0,
});
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [allTime, setAllTime] = useState<boolean>(false);
@ -87,6 +107,20 @@ export default function Metrics() {
});
setStats(sortedStats);
const firstStat = sortedStats[sortedStats.length - 1].data;
const lastStat = sortedStats[0].data;
const statsDiff = {
files: getMetricsDifference(firstStat.files, lastStat.files),
fileViews: getMetricsDifference(firstStat.fileViews, lastStat.fileViews),
storage: getMetricsDifference(firstStat.storage, lastStat.storage),
urls: getMetricsDifference(firstStat.urls, lastStat.urls),
urlViews: getMetricsDifference(firstStat.urlViews, lastStat.urlViews),
users: getMetricsDifference(firstStat.users, lastStat.users),
};
setStatsDifferences(statsDiff);
}
const windowWidth = Dimensions.get("window").width;
@ -179,47 +213,96 @@ export default function Metrics() {
{filteredStats && mainStat ? (
<View>
<ScrollView style={{height: "93%"}}>
<ScrollView
horizontal
style={{
...styles.scrollView,
...styles.statsContainer,
}}
>
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>Files:</Text>
<Text style={styles.statText}>{mainStat.data.files}</Text>
</View>
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>URLs:</Text>
<Text style={styles.statText}>{mainStat.data.urls}</Text>
</View>
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>Storage Used:</Text>
<Text style={styles.statText}>
{convertToBytes(mainStat.data.storage, {
<ScrollView style={{ height: "93%" }}>
<ScrollView horizontal style={styles.scrollView}>
{[
{
title: "Files:",
amount: mainStat.data.files,
difference: statsDifferences.files,
},
{
title: "URLs",
amount: mainStat.data.urls,
difference: statsDifferences.urls,
},
{
title: "Storage Used:",
amount: convertToBytes(mainStat.data.storage, {
unitSeparator: " ",
})}
</Text>
</View>
}),
difference: statsDifferences.storage,
},
{
title: "Users:",
amount: mainStat.data.users,
difference: statsDifferences.users,
},
{
title: "File Views:",
amount: mainStat.data.fileViews,
difference: statsDifferences.fileViews,
},
{
title: "URL Views:",
amount: mainStat.data.urlViews,
difference: statsDifferences.urlViews,
},
].map((stat) => (
<View key={stat.title} style={styles.statContainer}>
<Text style={styles.subHeaderText}>{stat.title}</Text>
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>Users:</Text>
<Text style={styles.statText}>{mainStat.data.users}</Text>
</View>
<View style={styles.statContainerData}>
<Text style={styles.statText}>{stat.amount}</Text>
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>File Views:</Text>
<Text style={styles.statText}>{mainStat.data.fileViews}</Text>
</View>
<View
style={{
...styles.statDifferenceContainer,
backgroundColor:
stat.difference === 0
? "#868E9640"
: stat.difference > 0
? "#40C05740"
: "#FA525240",
}}
>
{stat.difference > 0 ? (
<MaterialIcons
name="north"
size={18}
color="#69db7c"
/>
) : stat.difference < 0 ? (
<MaterialIcons
name="south"
size={18}
color="#ff8787"
/>
) : (
<MaterialIcons
name="remove"
size={18}
color="#ced4da"
/>
)}
<View style={styles.statContainer}>
<Text style={styles.subHeaderText}>URL Views:</Text>
<Text style={styles.statText}>{mainStat.data.urlViews}</Text>
</View>
<Text
style={{
...styles.statDifferenceText,
color:
stat.difference === 0
? "#ced4da"
: stat.difference > 0
? "#69db7c"
: "#ff8787",
}}
>
{stat.difference}%
</Text>
</View>
</View>
</View>
))}
</ScrollView>
{userSpecificMetrics && (

View file

@ -118,7 +118,7 @@ export default function UserSettings() {
const [generateThumbnailsRerun, setGenerateThumbnailsRerun] =
useState<boolean>(false);
const [ziplineVersion, setZiplineVersion] = useState<string | null>(null)
const [ziplineVersion, setZiplineVersion] = useState<string | null>(null);
const url = db.get("url") as DashURL;
@ -129,7 +129,7 @@ export default function UserSettings() {
const avatar = await getCurrentUserAvatar();
const exports = await getUserExports();
const zeroByteFiles = await getZeroByteFiles();
const versionData = await getVersion()
const versionData = await getVersion();
setUser(typeof user === "string" ? null : user);
setToken(typeof token === "string" ? null : token.token);
@ -138,7 +138,9 @@ export default function UserSettings() {
setZeroByteFiles(
typeof zeroByteFiles === "string" ? 0 : zeroByteFiles.files.length,
);
setZiplineVersion(typeof versionData === "string" ? null : versionData.version)
setZiplineVersion(
typeof versionData === "string" ? null : versionData.version,
);
})();
}, []);
@ -526,6 +528,7 @@ export default function UserSettings() {
placeholder="myPassword123"
value={password || ""}
password
description="Leave blank to keep the same password"
/>
<Button
@ -685,11 +688,15 @@ export default function UserSettings() {
{/* Viewing Files */}
<View style={styles.settingGroup}>
<Text style={styles.headerText}>Viewing Files</Text>
<Text style={styles.headerDescription}>
All text fields support using variables.
</Text>
<Switch
title="Enable View Routes"
value={viewEnabled || false}
onValueChange={() => setViewEnabled((prev) => !prev)}
description="Enable viewing files through customizable view-routes"
/>
<Switch
@ -697,10 +704,12 @@ export default function UserSettings() {
disabled={!viewEnabled}
value={viewShowMimetype || false}
onValueChange={() => setViewShowMimetype((prev) => !prev)}
description="Show the mimetype of the file in the view-route"
/>
<TextInput
title="View Content:"
description="Change the content within view-routes. Most HTML is valid, while the use of JavaScript is unavailable."
disabled={!viewEnabled}
disableContext={!viewEnabled}
multiline
@ -710,15 +719,9 @@ export default function UserSettings() {
placeholder="This is my file"
/>
<Text
style={{
...styles.inputHeader,
...(!viewEnabled && styles.inputHeaderDisabled),
}}
>
View Content Alignment:
</Text>
<Select
title="View Content Alignment:"
description="Change the alignment of the content within view-routes"
disabled={!viewEnabled}
data={alignments}
onSelect={(selectedAlignment) => {
@ -736,6 +739,7 @@ export default function UserSettings() {
<Switch
title="Embed"
description="Enable the following embed properties. These properties take advantage of OpenGraph tags. View routes will need to be enabled for this to work."
disabled={!viewEmbed || !viewEnabled}
value={viewEmbed || false}
onValueChange={() => setViewEmbed((prev) => !prev)}
@ -1016,6 +1020,9 @@ export default function UserSettings() {
{/* Server Actions */}
<View style={styles.settingGroup}>
<Text style={styles.headerText}>Server Actions</Text>
<Text style={styles.headerDescription}>
Helpful scripts and tools for server management.
</Text>
<View style={styles.serverActionButtonRow}>
<Button

View file

@ -52,7 +52,7 @@ export default function Home() {
// // db.del("url")
// // db.del("token")
// router.replace("/test");
// // router.replace("/test");
// }
// });

BIN
bun.lockb

Binary file not shown.

View file

@ -40,6 +40,7 @@ interface CustomColorPickerProps {
initialColor?: string;
showOpacity?: boolean;
showPreview?: boolean;
description?: string;
showInput?: boolean;
disabled?: boolean;
showHue?: boolean;
@ -85,6 +86,7 @@ export default function ColorPicker({
previewHideText,
showHue = true,
onSelectColor,
description,
title,
panel,
}: CustomColorPickerProps) {
@ -116,14 +118,23 @@ export default function ColorPicker({
return (
<>
{title && (
<Text
style={{
...styles.inputHeader,
...(disabled && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
<>
<Text
style={{
...styles.inputHeader,
...(!description && {
marginBottom: 5,
}),
...(disabled && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.inputDescription}>{description}</Text>
)}
</>
)}
<Pressable
disabled={disabled}

View file

@ -49,6 +49,16 @@ export default function LargeFolderView({ folder, dashUrl, onAction }: Props) {
onAction("visibility", folder);
},
},
{
name: folder.allowUploads
? "Deny Anonymous Uploads"
: "Allow Anonymous Uploads",
id: `${folder.id}-uploadPolicy`,
icon: folder.allowUploads ? "share" : "block",
onPress: async () => {
onAction("uploadPolicy", folder);
},
},
{
name: "Edit Name",
id: `${folder.id}-editName`,
@ -76,6 +86,9 @@ export default function LargeFolderView({ folder, dashUrl, onAction }: Props) {
},
},
]}
dropdown={{
width: 220,
}}
/>
</View>
@ -97,14 +110,18 @@ export default function LargeFolderView({ folder, dashUrl, onAction }: Props) {
<Text>{folder.public ? "Yes" : "No"}</Text>
</Text>
<Text style={styles.key}>
<Text style={styles.keyName}>Allow Anonymous Uploads</Text>:{" "}
<Text>{folder.allowUploads ? "Yes" : "No"}</Text>
</Text>
<Text style={styles.key}>
<Text style={styles.keyName}>Files</Text>:{" "}
<Text>{folder.files.length}</Text>
</Text>
<Text style={styles.key}>
<Text style={styles.keyName}>ID</Text>:{" "}
<Text>{folder.id}</Text>
<Text style={styles.keyName}>ID</Text>: <Text>{folder.id}</Text>
</Text>
</View>
</View>

View file

@ -29,7 +29,10 @@ export interface SelectProps {
item: SelectProps["data"][0],
key: string,
) => ReactNode | string;
renderItem?: (item: SelectProps["data"][0], closeSelect: () => void) => ReactNode;
renderItem?: (
item: SelectProps["data"][0],
closeSelect: () => void,
) => ReactNode;
id?: string;
width?: DimensionValue;
margin?: {
@ -38,7 +41,8 @@ export interface SelectProps {
left?: DimensionValue;
right?: DimensionValue;
};
title?: string
title?: string;
description?: string;
}
export default function Select({
@ -53,6 +57,7 @@ export default function Select({
multiple = false,
width,
title,
description,
margin = {},
renderSelectedItem = (item, key) => (
<Text
@ -125,14 +130,23 @@ export default function Select({
}}
>
{title && (
<Text
style={{
...styles.inputHeader,
...(disabled && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
<>
<Text
style={{
...styles.inputHeader,
...(!description && {
marginBottom: 5,
}),
...(disabled && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.inputDescription}>{description}</Text>
)}
</>
)}
<TouchableOpacity
style={styles.selectButton}
@ -209,7 +223,7 @@ export default function Select({
/>
)}
{renderItem(item, () => {
setIsOpen(false)
setIsOpen(false);
})}
</TouchableOpacity>
)}

View file

@ -6,6 +6,7 @@ import { View } from "react-native";
interface Props {
value: boolean;
title?: string;
description?: string;
onValueChange: (value: boolean, id?: Props["id"]) => void | Promise<void>;
disabled?: boolean;
id?: string;
@ -14,6 +15,7 @@ interface Props {
export default function Switch({
value,
title,
description,
onValueChange,
disabled = false,
id,
@ -30,15 +32,22 @@ export default function Switch({
false: "#181c28",
}}
/>
{title && (
<Text
style={{
...styles.switchText,
...(disabled && styles.switchTextDisabled),
}}
>
{title}
</Text>
<View style={{ flexDirection: "column" }}>
<Text
style={{
...styles.switchText,
...(disabled && styles.switchTextDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.switchDescription}>{description}</Text>
)}
</View>
)}
</View>
);

View file

@ -19,6 +19,7 @@ interface Props {
value?: string;
defaultValue?: string;
title?: string;
description?: string;
id?: string;
onPasswordToggle?: (
visibile: boolean,
@ -60,6 +61,7 @@ export default function TextInput({
disableContext = false,
multiline = false,
title,
description,
password = false,
keyboardType = "default",
placeholder,
@ -81,14 +83,25 @@ export default function TextInput({
return (
<View>
{title && (
<Text
style={{
...styles.inputHeader,
...(disabled && showDisabledStyle && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
<>
<Text
style={{
...styles.inputHeader,
...(!description && {
marginBottom: 5,
}),
...(disabled &&
showDisabledStyle &&
styles.inputHeaderDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.inputDescription}>{description}</Text>
)}
</>
)}
<View
style={{
@ -138,14 +151,25 @@ export default function TextInput({
return (
<View>
{title && (
<Text
style={{
...styles.inputHeader,
...(disabled && showDisabledStyle && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
<>
<Text
style={{
...styles.inputHeader,
...(!description && {
marginBottom: 5,
}),
...(disabled &&
showDisabledStyle &&
styles.inputHeaderDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.inputDescription}>{description}</Text>
)}
</>
)}
<View
style={{
@ -192,14 +216,23 @@ export default function TextInput({
return (
<View>
{title && (
<Text
style={{
...styles.inputHeader,
...(disabled && showDisabledStyle && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
<>
<Text
style={{
...styles.inputHeader,
...(!description && {
marginBottom: 5,
}),
...(disabled && showDisabledStyle && styles.inputHeaderDisabled),
}}
>
{title}
</Text>
{description && (
<Text style={styles.inputDescription}>{description}</Text>
)}
</>
)}
<NativeTextInput
onChange={(event) => onChange(event, id)}

View file

@ -348,6 +348,7 @@ interface Category {
| "OIDCOAuth"
| "discordOnUploadEmbed"
| "discordOnShortenEmbed"}Category`;
description?: string;
}
interface Input {
@ -360,6 +361,7 @@ interface Input {
multiline?: boolean;
type: "input";
name: string;
description?: string;
}
interface Switch {
@ -367,6 +369,7 @@ interface Switch {
setting: SettingPath;
type: "switch";
name: string;
description: string;
}
interface Select {
@ -377,12 +380,14 @@ interface Select {
placeholder: string;
type: "select";
name: string;
description: string;
}
interface ColorPicker {
type: "colorPicker";
setting: SettingPath;
name: string;
description: string;
}
interface Save {
@ -413,6 +418,7 @@ export const settings: Array<Setting> = [
type: "switch",
name: "Return HTTPS URls",
setting: "coreReturnHttpsUrls",
description: "Return URLs with HTTPS protocol.",
},
{
type: "input",
@ -420,6 +426,8 @@ export const settings: Array<Setting> = [
keyboardType: "url",
setting: "coreDefaultDomain",
placeholder: "example.com",
description:
"The domain to use when generating URLs. This value should not include the protocol.",
},
{
type: "input",
@ -427,6 +435,8 @@ export const settings: Array<Setting> = [
keyboardType: "default",
setting: "coreTempDirectory",
placeholder: "/tmp/zipline",
description:
"The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.",
},
{
type: "save",
@ -439,6 +449,12 @@ export const settings: Array<Setting> = [
name: "Chunks",
category: "chunksCategory",
children: [
{
type: "switch",
name: "Enable Chunks",
setting: "chunksEnabled",
description: "Enable chunked uploads.",
},
{
type: "input",
name: "Max Chunks Size",
@ -446,6 +462,8 @@ export const settings: Array<Setting> = [
setting: "chunksMax",
placeholder: "95mb",
displayType: "bytes",
description:
"Maximum size of an upload before it is split into chunks.",
},
{
type: "input",
@ -454,6 +472,7 @@ export const settings: Array<Setting> = [
setting: "chunksSize",
placeholder: "25mb",
displayType: "bytes",
description: "Size of each chunk.",
},
{
type: "save",
@ -464,6 +483,7 @@ export const settings: Array<Setting> = [
{
type: "category",
name: "Tasks",
description: "All options require a restart to take effect.",
category: "tasksCategory",
children: [
{
@ -473,6 +493,7 @@ export const settings: Array<Setting> = [
setting: "tasksDeleteInterval",
placeholder: "30m",
displayType: "time",
description: "How often to check and delete expired files.",
},
{
type: "input",
@ -481,6 +502,7 @@ export const settings: Array<Setting> = [
setting: "tasksClearInvitesInterval",
placeholder: "30m",
displayType: "time",
description: "How often to check and clear expired/used invites.",
},
{
type: "input",
@ -489,6 +511,8 @@ export const settings: Array<Setting> = [
setting: "tasksMaxViewsInterval",
placeholder: "30m",
displayType: "time",
description:
"How often to check and delete files that have reached max views.",
},
{
type: "input",
@ -497,6 +521,8 @@ export const settings: Array<Setting> = [
setting: "tasksThumbnailsInterval",
placeholder: "5m",
displayType: "time",
description:
"How often to check and generate thumbnails for video files.",
},
// {
// type: "input",
@ -521,11 +547,15 @@ export const settings: Array<Setting> = [
type: "switch",
name: "Passkeys",
setting: "mfaPasskeys",
description:
"Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.",
},
{
type: "switch",
name: "TOTP Enabled",
setting: "mfaTotpEnabled",
description:
"Enable Time-based One-Time Passwords with the use of an authenticator app.",
},
{
type: "input",
@ -533,6 +563,7 @@ export const settings: Array<Setting> = [
keyboardType: "default",
setting: "mfaTotpIssuer",
placeholder: "Zipline",
description: "The issuer to use for the TOTP token.",
},
{
type: "save",
@ -549,51 +580,67 @@ export const settings: Array<Setting> = [
type: "switch",
name: "Image Compression",
setting: "featuresImageCompression",
description: "Allows the ability for users to compress images.",
},
{
type: "switch",
name: "/robots.txt",
setting: "featuresRobotsTxt",
description:
"Enables a robots.txt file for search engine optimization. Requires a server restart.",
},
{
type: "switch",
name: "Healthcheck",
setting: "featuresHealthcheck",
description:
"Enables a healthcheck route for uptime monitoring. Requires a server restart.",
},
{
type: "switch",
name: "User Registration",
setting: "featuresUserRegistration",
description: "Allows users to register an account on the server.",
},
{
type: "switch",
name: "OAuth Registration",
setting: "featuresOauthRegistration",
description:
"Allows users to register an account using OAuth providers.",
},
{
type: "switch",
name: "Delete on Max Views",
setting: "featuresDeleteOnMaxViews",
description:
"Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.",
},
{
type: "switch",
name: "Enable Metrics",
setting: "featuresMetricsEnabled",
description:
"Enables metrics for the server. Requires a server restart.",
},
{
type: "switch",
name: "Admin Only Metrics",
setting: "featuresMetricsAdminOnly",
description: "Requires an administrator to view metrics.",
},
{
type: "switch",
name: "Show User Specific Metrics",
setting: "featuresMetricsShowUserSpecific",
description: "Shows metrics specific to each user, for all users.",
},
{
type: "switch",
name: "Enable Thumbnails",
setting: "featuresThumbnailsEnabled",
description:
"Enables thumbnail generation for images. Requires a server restart.",
},
{
type: "input",
@ -601,6 +648,8 @@ export const settings: Array<Setting> = [
setting: "featuresThumbnailsNumberThreads",
keyboardType: "numeric",
placeholder: "Enter a number...",
description:
"Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.",
},
{
type: "save",
@ -618,22 +667,28 @@ export const settings: Array<Setting> = [
name: "Route",
setting: "filesRoute",
keyboardType: "default",
description:
"The route to use for file uploads. Requires a server restart.",
},
{
type: "input",
name: "Length",
setting: "filesLength",
keyboardType: "numeric",
description:
"The length of the file name (for randomly generated names).",
},
{
type: "switch",
name: "Assume Mimetypes",
setting: "filesAssumeMimetypes",
description: "Assume the mimetype of a file for its extension.",
},
{
type: "switch",
name: "Remove GPS Metadata",
setting: "filesRemoveGpsMetadata",
description: "Remove GPS metadata from files.",
},
{
type: "select",
@ -642,6 +697,7 @@ export const settings: Array<Setting> = [
defaultValue: "random",
setting: "filesDefaultFormat",
placeholder: "Select format...",
description: "The default format to use for file names.",
},
{
type: "input",
@ -649,6 +705,7 @@ export const settings: Array<Setting> = [
setting: "filesDisabledExtensions",
keyboardType: "default",
joinString: ", ",
description: "Extensions to disable, separated by commas.",
},
{
type: "input",
@ -656,30 +713,36 @@ export const settings: Array<Setting> = [
setting: "filesMaxFileSize",
keyboardType: "default",
displayType: "bytes",
description: "The maximum file size allowed.",
},
{
type: "input",
name: "Default Expiration",
setting: "filesDefaultExpiration",
keyboardType: "default",
description: "The default expiration time for files.",
},
{
type: "input",
name: "Default Date Format",
setting: "filesDefaultDateFormat",
keyboardType: "default",
description: "The default date format to use.",
},
{
type: "input",
name: "Random Words Num Adjectives",
setting: "filesRandomWordsNumAdjectives",
keyboardType: "numeric",
description:
"The number of adjectives to use for the random-words/gfycat format.",
},
{
type: "input",
name: "Random Words Separator",
setting: "filesRandomWordsSeparator",
placeholder: "-",
description: "The separator to use for the random-words/gfycat format.",
},
{
type: "save",
@ -697,12 +760,16 @@ export const settings: Array<Setting> = [
name: "Route",
setting: "urlsRoute",
keyboardType: "default",
description:
"The route to use for short URLs. Requires a server restart.",
},
{
type: "input",
name: "Length",
setting: "urlsLength",
keyboardType: "numeric",
description:
"The length of the short URL (for randomly generated names).",
},
{
type: "save",
@ -719,12 +786,14 @@ export const settings: Array<Setting> = [
type: "switch",
name: "Enable Invites",
setting: "invitesEnabled",
description: "Enable the use of invite links to register new users.",
},
{
type: "input",
name: "Length",
setting: "invitesLength",
keyboardType: "numeric",
description: "The length of the invite code.",
},
{
type: "save",
@ -735,29 +804,35 @@ export const settings: Array<Setting> = [
{
type: "category",
name: "Ratelimit",
description: "All options require a restart to take effect.",
category: "ratelimitCategory",
children: [
{
type: "switch",
name: "Enable Ratelimit",
setting: "ratelimitEnabled",
description: "Enable ratelimiting for the server.",
},
{
type: "switch",
name: "Admin Bypass",
setting: "ratelimitAdminBypass",
description: "Allow admins to bypass the ratelimit.",
},
{
type: "input",
name: "Max Requests",
setting: "ratelimitMax",
keyboardType: "numeric",
description:
"The maximum number of requests allowed within the window. If no window is set, this is the maximum number of requests until it reaches the limit.",
},
{
type: "input",
name: "Window",
setting: "ratelimitWindow",
keyboardType: "numeric",
description: "The window in seconds to allow the max requests.",
},
{
type: "input",
@ -765,6 +840,8 @@ export const settings: Array<Setting> = [
setting: "ratelimitAllowList",
joinString: ", ",
keyboardType: "default",
description:
"A comma-separated list of IP addresses to bypass the ratelimit.",
},
{
type: "save",
@ -783,56 +860,70 @@ export const settings: Array<Setting> = [
name: "Title",
setting: "websiteTitle",
keyboardType: "default",
description: "The title of the website in browser tabs and at the top.",
},
{
type: "input",
name: "Title Logo",
setting: "websiteTitleLogo",
keyboardType: "url",
description:
"The URL to use for the title logo. This is placed to the left of the title.",
},
{
type: "externalUrls",
},
{
type: "input",
name: "Login Backgorund",
name: "Login Background",
setting: "websiteLoginBackground",
keyboardType: "url",
description: "The URL to use for the login background.",
},
{
type: "switch",
name: "Login Backgorund Blur",
name: "Login Background Blur",
setting: "websiteLoginBackgroundBlur",
description: "Whether to blur the login background.",
},
{
type: "input",
name: "Default Avatar",
setting: "websiteDefaultAvatar",
keyboardType: "default",
description:
"The path to use for the default avatar. This must be a path to an image, not a URL.",
},
{
type: "input",
name: "Terms of Service",
setting: "websiteTos",
keyboardType: "default",
description:
"Path to a Markdown (.md) file to use for the terms of service.",
},
{
type: "input",
name: "Default Theme",
setting: "websiteThemeDefault",
keyboardType: "default",
description: "The default theme to use for the website.",
},
{
type: "input",
name: "Dark Theme",
setting: "websiteThemeDark",
keyboardType: "default",
description:
'The dark theme to use for the website when the default theme is "system".',
},
{
type: "input",
name: "Light Theme",
setting: "websiteThemeLight",
keyboardType: "default",
description:
'The light theme to use for the website when the default theme is "system".',
},
{
type: "save",
@ -843,17 +934,23 @@ export const settings: Array<Setting> = [
{
type: "category",
name: "OAuth",
description:
'For OAuth to work, the "OAuth Registration" setting must be enabled in the Features section. If you have issues, try restarting Zipline after saving.',
category: "oauthCategory",
children: [
{
type: "switch",
name: "Bypass Local Login",
setting: "oauthBypassLocalLogin",
description:
"Skips the local login page and redirects to the OAuth provider, this only works with one provider enabled.",
},
{
type: "switch",
name: "Login Only",
setting: "oauthLoginOnly",
description:
"Disables registration and only allows login with OAuth, existing users can link providers for example.",
},
{
type: "category",
@ -878,6 +975,8 @@ export const settings: Array<Setting> = [
name: "Discord Redirect URI",
setting: "oauthDiscordRedirectUri",
keyboardType: "default",
description:
"The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.",
},
],
},
@ -904,6 +1003,8 @@ export const settings: Array<Setting> = [
name: "Google Redirect URI",
setting: "oauthGoogleRedirectUri",
keyboardType: "default",
description:
"The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.",
},
],
},
@ -930,6 +1031,8 @@ export const settings: Array<Setting> = [
name: "GitHub Redirect URI",
setting: "oauthGithubRedirectUri",
keyboardType: "default",
description:
"The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.",
},
],
},
@ -974,6 +1077,8 @@ export const settings: Array<Setting> = [
name: "OIDC Redirect URL",
setting: "oauthOidcRedirectUri",
keyboardType: "default",
description:
"The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.",
},
],
},
@ -986,12 +1091,14 @@ export const settings: Array<Setting> = [
{
type: "category",
name: "PWA",
description: "Refresh the page after enabling PWA to see any changes.",
category: "pwaCategory",
children: [
{
type: "switch",
name: "PWA Enabled",
setting: "pwaEnabled",
description: "Allow users to install the Zipline PWA on their devices.",
},
{
type: "input",
@ -999,6 +1106,7 @@ export const settings: Array<Setting> = [
setting: "pwaTitle",
keyboardType: "default",
placeholder: "Zipline",
description: "The title for the PWA",
},
{
type: "input",
@ -1006,6 +1114,7 @@ export const settings: Array<Setting> = [
setting: "pwaShortName",
keyboardType: "default",
placeholder: "Zipline",
description: "The short name for the PWA",
},
{
type: "input",
@ -1013,6 +1122,7 @@ export const settings: Array<Setting> = [
setting: "pwaDescription",
keyboardType: "default",
placeholder: "Zipline",
description: "The description for the PWA",
},
{
type: "input",
@ -1020,11 +1130,13 @@ export const settings: Array<Setting> = [
setting: "pwaThemeColor",
keyboardType: "default",
placeholder: "#000000",
description: "The theme color for the PWA",
},
{
type: "colorPicker",
name: "Background Color",
setting: "pwaBackgroundColor",
description: "The background color for the PWA",
},
{
type: "save",
@ -1043,6 +1155,8 @@ export const settings: Array<Setting> = [
setting: "httpWebhookOnUpload",
keyboardType: "url",
placeholder: "https://example.com/upload",
description:
"The URL to send a POST request to when a file is uploaded.",
},
{
type: "input",
@ -1050,6 +1164,8 @@ export const settings: Array<Setting> = [
setting: "httpWebhookOnShorten",
keyboardType: "url",
placeholder: "https://example.com/shorten",
description:
"The URL to send a POST request to when a URL is shortened.",
},
{
type: "save",
@ -1068,6 +1184,7 @@ export const settings: Array<Setting> = [
setting: "discordWebhookUrl",
keyboardType: "url",
placeholder: "https://discord.com/api/webhooks/...",
description: "The Discord webhook URL to send notifications to",
},
{
type: "input",
@ -1075,6 +1192,7 @@ export const settings: Array<Setting> = [
setting: "discordUsername",
keyboardType: "default",
placeholder: "Zipline",
description: "The username to send notifications as",
},
{
type: "input",
@ -1082,6 +1200,7 @@ export const settings: Array<Setting> = [
setting: "discordAvatarUrl",
keyboardType: "url",
placeholder: "https://example.com/avatar.png",
description: "The avatar for the webhook",
},
{
type: "save",
@ -1098,6 +1217,8 @@ export const settings: Array<Setting> = [
setting: "discordOnUploadWebhookUrl",
keyboardType: "url",
placeholder: "https://discord.com/api/webhooks/...",
description:
"The Discord webhook URL to send notifications to. If this is left blank, the main webhook url will be used",
},
{
type: "input",
@ -1105,6 +1226,8 @@ export const settings: Array<Setting> = [
setting: "discordOnUploadUsername",
keyboardType: "default",
placeholder: "Zipline Uploads",
description:
"The username to send notifications as. If this is left blank, the main username will be used",
},
{
type: "input",
@ -1112,6 +1235,8 @@ export const settings: Array<Setting> = [
setting: "discordOnUploadAvatarUrl",
keyboardType: "url",
placeholder: "https://example.com/uploadAvatar.png",
description:
"The avatar for the webhook. If this is left blank, the main avatar will be used",
},
{
type: "input",
@ -1119,12 +1244,16 @@ export const settings: Array<Setting> = [
setting: "discordOnUploadContent",
keyboardType: "default",
multiline: true,
description:
"The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out",
},
{
type: "switch",
name: "Embed",
setting: "discordOnUploadEmbed",
setType: "upload",
description:
"Send the notification as an embed. This will allow for more customization below.",
},
{
type: "category",
@ -1137,43 +1266,53 @@ export const settings: Array<Setting> = [
name: "Title",
setting: "discordOnUploadEmbed.title",
keyboardType: "default",
description: "The title of the embed",
},
{
type: "input",
name: "Description",
setting: "discordOnUploadEmbed.description",
keyboardType: "default",
description: "The description of the embed",
},
{
type: "input",
name: "Footer",
setting: "discordOnUploadEmbed.footer",
keyboardType: "default",
description: "The footer of the embed",
},
{
type: "colorPicker",
name: "Color",
setting: "discordOnUploadEmbed.color",
description: "The color of the embed",
},
{
type: "switch",
name: "Thumbnail",
setting: "discordOnUploadEmbed.thumbnail",
description:
"Show the thumbnail (it will show the file if it's an image) in the embed",
},
{
type: "switch",
name: "Image/Video",
setting: "discordOnUploadEmbed.imageOrVideo",
description: "Show the image or video in the embed",
},
{
type: "switch",
name: "Timestamp",
setting: "discordOnUploadEmbed.timestamp",
description: "Show the timestamp in the embed",
},
{
type: "switch",
name: "URL",
setting: "discordOnUploadEmbed.url",
description:
"Makes the title clickable and links to the URL of the file",
},
],
},
@ -1194,6 +1333,8 @@ export const settings: Array<Setting> = [
setting: "discordOnShortenWebhookUrl",
keyboardType: "url",
placeholder: "https://discord.com/api/webhooks/...",
description:
"The Discord webhook URL to send notifications to. If this is left blank, the main webhook url will be used",
},
{
type: "input",
@ -1201,6 +1342,8 @@ export const settings: Array<Setting> = [
setting: "discordOnShortenUsername",
keyboardType: "default",
placeholder: "Zipline Shortens",
description:
"The username to send notifications as. If this is left blank, the main username will be used",
},
{
type: "input",
@ -1208,6 +1351,8 @@ export const settings: Array<Setting> = [
setting: "discordOnShortenAvatarUrl",
keyboardType: "url",
placeholder: "https://example.com/shortenAvatar.png",
description:
"The avatar for the webhook. If this is left blank, the main avatar will be used",
},
{
type: "input",
@ -1215,11 +1360,15 @@ export const settings: Array<Setting> = [
setting: "discordOnShortenContent",
keyboardType: "default",
multiline: true,
description:
"The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out",
},
{
type: "switch",
name: "Embed",
setting: "discordOnShortenEmbed",
description:
"Send the notification as an embed. This will allow for more customization below.",
},
{
type: "category",
@ -1232,33 +1381,40 @@ export const settings: Array<Setting> = [
name: "Title",
setting: "discordOnShortenEmbed.title",
keyboardType: "default",
description: "The title of the embed",
},
{
type: "input",
name: "Description",
setting: "discordOnShortenEmbed.description",
keyboardType: "default",
description: "The description of the embed",
},
{
type: "input",
name: "Footer",
setting: "discordOnShortenEmbed.footer",
keyboardType: "default",
description: "The footer of the embed",
},
{
type: "colorPicker",
name: "Color",
setting: "discordOnShortenEmbed.color",
description: "The color of the embed",
},
{
type: "switch",
name: "Timestamp",
setting: "discordOnShortenEmbed.timestamp",
description: "Show the timestamp in the embed",
},
{
type: "switch",
name: "URL",
setting: "discordOnShortenEmbed.url",
description:
"Makes the title clickable and links to the URL of the file",
},
],
},

View file

@ -154,3 +154,6 @@ export function getRippleColor(color: string, fraction = 0.4) {
return rgbaToHex(newRed, newGreen, newBlue);
}
export function getMetricsDifference(first: number, last: number): number {
return Math.round(((first - last) / last) * 100) || 0;
}

View file

@ -253,13 +253,15 @@ export async function uploadFiles(
const token = db.get("token");
const url = db.get("url");
if (!url || !token) return "Invalid token or URL";
if (!url) return "Invalid URL";
if (!options.folder && !token) return "Invalid token";
const headers: {
[key: string]: string;
} = {};
headers.Authorization = token;
if (token) headers.Authorization = token;
headers["X-Zipline-Format"] = options.format?.toLowerCase() || "random";
if (options.compression)

View file

@ -80,8 +80,6 @@ export async function getUserStats(): Promise<APIUserStats | string> {
}
}
export function getChartFiles(stats: APIStats) {}
export function filterStats(data: APIStats, amount = 100) {
data.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),

View file

@ -1,4 +1,4 @@
import type { APIURLs, APIURL } from "@/types/zipline";
import type { APIURLs, APIURL, APIShortenResponse } from "@/types/zipline";
import axios, { type AxiosError } from "axios";
import * as db from "@/functions/database";
@ -91,12 +91,7 @@ export async function createURL({
maxViews,
password,
enabled = true,
}: CreateURLParams): Promise<
| {
url: string;
}
| string
> {
}: CreateURLParams): Promise<APIShortenResponse | string> {
const token = db.get("token");
const url = db.get("url");
@ -123,7 +118,7 @@ export async function createURL({
},
);
return res.data;
return res.data as APIShortenResponse;
} catch (e) {
const error = e as AxiosError;

View file

@ -1,7 +1,7 @@
{
"name": "zipline",
"main": "expo-router/entry",
"version": "1.7.0",
"version": "1.8.0",
"scripts": {
"start": "expo start",
"start:dev": "APP_VARIANT=development EDGE_PATH=/home/stef/.local/bin/google-chrome expo start --dev-client",

View file

@ -22,7 +22,7 @@ export const styles = StyleSheet.create({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 5
marginVertical: 5,
},
headerText: {
marginTop: 5,
@ -32,6 +32,10 @@ export const styles = StyleSheet.create({
fontWeight: "bold",
color: "white",
},
headerDescription: {
color: "#6c7a8d",
marginLeft: 10,
},
errorText: {
color: "red",
fontWeight: "bold",
@ -68,15 +72,18 @@ export const styles = StyleSheet.create({
},
externalLinkTitle: {
marginTop: 10,
marginBottom: 5,
fontSize: 18,
fontWeight: "bold",
color: "white",
},
externalLinkDescription: {
color: "#6c7a8d",
marginBottom: 5,
},
popupContent: {
height: "auto",
width: "100%",
zIndex: 99999999999
zIndex: 99999999999,
},
mainHeaderText: {
fontSize: 22,

View file

@ -3,11 +3,14 @@ import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
inputHeader: {
marginTop: 5,
marginBottom: 5,
fontSize: 18,
fontWeight: "bold",
color: "white",
},
inputDescription: {
color: "#6c7a8d",
marginBottom: 5,
},
inputHeaderDisabled: {
color: "gray",
},

View file

@ -66,11 +66,14 @@ export const styles = StyleSheet.create({
},
inputHeader: {
marginTop: 5,
marginBottom: 5,
fontSize: 18,
fontWeight: "bold",
color: "white",
},
inputDescription: {
color: "#6c7a8d",
marginBottom: 5,
},
inputHeaderDisabled: {
color: "gray",
},

View file

@ -4,6 +4,7 @@ export const styles = StyleSheet.create({
switchContainer: {
flexDirection: "row",
marginTop: 10,
width: "85%",
},
switchText: {
color: "white",
@ -12,6 +13,10 @@ export const styles = StyleSheet.create({
fontWeight: "bold",
textAlignVertical: "center",
},
switchDescription: {
color: "#6c7a8d",
marginLeft: 5,
},
switchTextDisabled: {
color: "gray",
},

View file

@ -19,11 +19,14 @@ export const styles = StyleSheet.create({
},
inputHeader: {
marginTop: 5,
marginBottom: 5,
fontSize: 18,
fontWeight: "bold",
color: "white",
},
inputDescription: {
color: "#6c7a8d",
marginBottom: 5,
},
textInputDisabled: {
color: "gray",
},

112
styles/folders/upload.ts Normal file
View file

@ -0,0 +1,112 @@
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
mainContainer: {
backgroundColor: "#0c101c",
flex: 1,
},
headerText: {
marginTop: 5,
marginLeft: 10,
fontSize: 23,
fontWeight: "bold",
color: "white",
},
subHeaderText: {
marginLeft: 10,
fontSize: 13,
fontWeight: "bold",
color: "gray",
},
scrollView: {
marginTop: 10,
borderStyle: "solid",
borderWidth: 2,
borderColor: "#222c47",
marginHorizontal: 10,
borderRadius: 15,
height: 220,
},
scrollViewKeyboardOpen: {
display: "none",
},
recentFileContainer: {
marginHorizontal: 10,
marginVertical: 7.5,
},
inputHeader: {
marginTop: 5,
fontSize: 18,
fontWeight: "bold",
color: "white",
},
inputHeaderDisabled: {
color: "gray",
},
inputsContainer: {
width: "90%",
marginHorizontal: "auto",
marginTop: 5,
marginBottom: 5,
},
uploadedFileContainer: {
flexDirection: "row",
alignItems: "center",
},
uploadedFileUrl: {
textAlignVertical: "center",
color: "#575db5",
width: "70%",
},
failedFileText: {
textAlign: "center",
color: "white",
},
popupScrollView: {
flexGrow: 0,
flexShrink: 1,
maxHeight: 400,
},
popupSubHeaderText: {
marginTop: 10,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
headerButtons: {
flexDirection: "row",
justifyContent: "space-around",
paddingTop: 10,
paddingRight: 10,
},
errorText: {
color: "red",
fontWeight: "bold",
textAlign: "center",
},
selectText: {
flex: 1,
textAlignVertical: "center",
fontSize: 15,
color: "white",
},
selectButtonContainer: {
flexDirection: "row",
},
selectItemContainer: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
folderStatusText: {
color: "#6c7a8d",
marginTop: 10,
marginHorizontal: 20,
textAlign: "center",
},
folderStatusLink: {
color: "#575db5",
},
});

View file

@ -28,7 +28,7 @@ export const styles = StyleSheet.create({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10
marginBottom: 10,
},
dateRangeText: {
color: "gray",
@ -42,11 +42,7 @@ export const styles = StyleSheet.create({
borderColor: "#222c47",
marginHorizontal: 10,
borderRadius: 15,
padding: 15,
marginBottom: 10
},
statsContainer: {
paddingHorizontal: 7,
marginBottom: 10,
},
subHeaderText: {
fontSize: 18,
@ -66,6 +62,21 @@ export const styles = StyleSheet.create({
borderRadius: 10,
padding: 10,
marginHorizontal: 4,
marginVertical: 10,
},
statContainerData: {
flexDirection: "row",
},
statDifferenceContainer: {
margin: 10,
marginTop: 15,
marginBottom: 5,
padding: 4,
borderRadius: 8,
flexDirection: "row",
},
statDifferenceText: {
marginLeft: 5,
},
chartContainer: {
margin: 10,

View file

@ -34,6 +34,10 @@ export const styles = StyleSheet.create({
fontWeight: "bold",
color: "white",
},
headerDescription: {
marginLeft: 10,
color: "#6c7a8d",
},
errorText: {
color: "red",
fontWeight: "bold",

View file

@ -144,8 +144,23 @@ export interface APIUploadFile {
export interface APIUploadResponse {
files: Array<APIUploadFile>;
partialSuccess: boolean;
assumedMimetypes: Array<string | null | APIUploadFile>;
partialSuccess?: boolean;
assumedMimetypes: Array<boolean>;
deletesAt?: string;
}
export interface APIShortenResponse {
id: string;
createdAt: string;
updatedAt: string;
code: string;
vanity: string | null;
destination: string;
views: number;
maxViews: number | null;
enabled: true;
userId: string;
url: string;
}
export interface APITransactionResult {