Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca73ccf629 | ||
|
|
9f2097e788 | ||
|
|
2158df4228 | ||
|
|
37e765ddc7 | ||
|
|
a91c531642 | ||
|
|
5a7f7ca2f6 | ||
|
|
813ee4de2c | ||
|
|
b25c30d1ed | ||
|
|
c807d208d8 | ||
|
|
f82099f36e | ||
|
|
6345e21db9 | ||
|
|
f55aa80516 | ||
|
|
0ce8b528e1 | ||
|
|
8ff417a013 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,3 +1,26 @@
|
||||
### [0.13.1](https://github.com/stonith404/pingvin-share/compare/v0.13.0...v0.13.1) (2023-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty file can't be uploaded in chrome ([9f2097e](https://github.com/stonith404/pingvin-share/commit/9f2097e788dfb79c2f95085025934c3134a3eb38))
|
||||
|
||||
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
|
||||
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
|
||||
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
|
||||
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
|
||||
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
|
||||
|
||||
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
|
||||
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,26 +1,26 @@
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:18-slim AS frontend-dependencies
|
||||
FROM node:19-slim AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: on frontend change
|
||||
FROM node:18-slim AS frontend-builder
|
||||
FROM node:19-slim AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: on backend dependency change
|
||||
FROM node:18-slim AS backend-dependencies
|
||||
FROM node:19-slim AS backend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4:on backend change
|
||||
FROM node:18-slim AS backend-builder
|
||||
FROM node:19-slim AS backend-builder
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
@@ -29,7 +29,7 @@ RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:18-slim AS runner
|
||||
FROM node:19-slim AS runner
|
||||
ENV NODE_ENV=docker
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ Setup
|
||||
|
||||
@@ -33,7 +33,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 14
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.3.9",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
@@ -95,7 +97,7 @@ export class AuthTotpService {
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
"pingvin-share",
|
||||
this.config.get("general.appName"),
|
||||
secret
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
@@ -6,6 +10,7 @@ import { ConfigService } from "src/config/config.service";
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
|
||||
getTransporter() {
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
@@ -22,6 +27,22 @@ export class EmailService {
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMail(email: string, subject: string, text: string) {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
throw new InternalServerErrorException("Failed to send email");
|
||||
});
|
||||
}
|
||||
|
||||
async sendMailToShareRecepients(
|
||||
recipientEmail: string,
|
||||
shareId: string,
|
||||
@@ -32,34 +53,28 @@ export class EmailService {
|
||||
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.shareRecipientsSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.shareRecipientsSubject"),
|
||||
this.config
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.reverseShareSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.reverseShareSubject"),
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
@@ -67,47 +82,42 @@ export class EmailService {
|
||||
"general.appUrl"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.resetPasswordSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.resetPasswordSubject"),
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.inviteSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.inviteSubject"),
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password),
|
||||
});
|
||||
.replaceAll("{password}", password)
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
text: "This is a test email",
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export class FileController {
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
const data = body.toString().split(",")[1];
|
||||
// Data can be empty if the file is empty
|
||||
const data = body.toString().split(",")[1] ?? "";
|
||||
|
||||
return await this.fileService.create(
|
||||
data,
|
||||
@@ -51,7 +52,7 @@ export class FileController {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
|
||||
@@ -278,7 +278,7 @@ export class ShareService {
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password");
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
@@ -29,7 +29,7 @@ export class UserSevice {
|
||||
if (!dto.password) {
|
||||
const randomPassword = crypto.randomUUID();
|
||||
hash = await argon.hash(randomPassword);
|
||||
this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||
await this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||
} else {
|
||||
hash = await argon.hash(dto.password);
|
||||
}
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
@@ -27,7 +26,7 @@ const showEnableTotpModal = (
|
||||
}
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Enable TOTP</Title>,
|
||||
title: "Enable TOTP",
|
||||
children: (
|
||||
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
@@ -19,7 +18,7 @@ const showCreateUserModal = (
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Create user</Title>,
|
||||
title: "Create user",
|
||||
children: (
|
||||
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
||||
),
|
||||
@@ -79,13 +78,12 @@ const Body = ({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{form.values.setPasswordManually ||
|
||||
(!smtpEnabled && (
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
))}
|
||||
{(form.values.setPasswordManually || !smtpEnabled) && (
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
styles={{
|
||||
body: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
@@ -21,7 +20,7 @@ const showUpdateUserModal = (
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Update {user.username}</Title>,
|
||||
title: `Update ${user.username}`,
|
||||
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
||||
});
|
||||
};
|
||||
|
||||
41
frontend/src/components/core/SortIcon.tsx
Normal file
41
frontend/src/components/core/SortIcon.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbChevronDown, TbChevronUp, TbSelector } from "react-icons/tb";
|
||||
|
||||
export type TableSort = {
|
||||
property?: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
const TableSortIcon = ({
|
||||
sort,
|
||||
setSort,
|
||||
property,
|
||||
}: {
|
||||
sort: TableSort;
|
||||
setSort: Dispatch<SetStateAction<TableSort>>;
|
||||
property: string;
|
||||
}) => {
|
||||
if (sort.property === property) {
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
setSort({
|
||||
property,
|
||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
{sort.direction === "asc" ? <TbChevronDown /> : <TbChevronUp />}
|
||||
</ActionIcon>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ActionIcon onClick={() => setSort({ property, direction: "asc" })}>
|
||||
<TbSelector />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TableSortIcon;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -8,9 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import mime from "mime-types";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
@@ -18,13 +17,17 @@ import { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
import TableSortIcon, { TableSort } from "../core/SortIcon";
|
||||
import showFilePreviewModal from "./modals/showFilePreviewModal";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
setShare,
|
||||
share,
|
||||
isLoading,
|
||||
}: {
|
||||
files?: FileMetaData[];
|
||||
setShare: Dispatch<SetStateAction<Share | undefined>>;
|
||||
share: Share;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
@@ -32,6 +35,32 @@ const FileList = ({
|
||||
const config = useConfig();
|
||||
const modals = useModals();
|
||||
|
||||
const [sort, setSort] = useState<TableSort>({
|
||||
property: undefined,
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const sortFiles = () => {
|
||||
if (files && sort.property) {
|
||||
const sortedFiles = files.sort((a: any, b: any) => {
|
||||
if (sort.direction === "asc") {
|
||||
return b[sort.property!].localeCompare(a[sort.property!], undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
} else {
|
||||
return a[sort.property!].localeCompare(b[sort.property!], undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setShare({
|
||||
...share,
|
||||
files: sortedFiles,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyFileLink = (file: FileMetaData) => {
|
||||
const link = `${config.get("general.appUrl")}/api/shares/${
|
||||
share.id
|
||||
@@ -52,55 +81,70 @@ const FileList = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(sortFiles, [sort]);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
Name
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="name" />
|
||||
</Group>
|
||||
</th>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
Size
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="size" />
|
||||
</Group>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
showFilePreviewModal(share.id, file, modals)
|
||||
}
|
||||
size={25}
|
||||
>
|
||||
<TbEye />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={() => copyFileLink(file)}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/share/${share.id}/preview/${
|
||||
file.id
|
||||
}?type=${mime.contentType(file.name)}`}
|
||||
target="_blank"
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbEye />
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
158
frontend/src/components/share/FilePreview.tsx
Normal file
158
frontend/src/components/share/FilePreview.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Button, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import Link from "next/link";
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import api from "../../services/api.service";
|
||||
|
||||
const FilePreviewContext = React.createContext<{
|
||||
shareId: string;
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
setIsNotSupported: Dispatch<SetStateAction<boolean>>;
|
||||
}>({
|
||||
shareId: "",
|
||||
fileId: "",
|
||||
mimeType: "",
|
||||
setIsNotSupported: () => {},
|
||||
});
|
||||
|
||||
const FilePreview = ({
|
||||
shareId,
|
||||
fileId,
|
||||
mimeType,
|
||||
}: {
|
||||
shareId: string;
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
}) => {
|
||||
const [isNotSupported, setIsNotSupported] = useState(false);
|
||||
if (isNotSupported) return <UnSupportedFile />;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<FilePreviewContext.Provider
|
||||
value={{ shareId, fileId, mimeType, setIsNotSupported }}
|
||||
>
|
||||
<FileDecider />
|
||||
</FilePreviewContext.Provider>
|
||||
<Button
|
||||
variant="subtle"
|
||||
component={Link}
|
||||
onClick={() => modals.closeAll()}
|
||||
target="_blank"
|
||||
href={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
>
|
||||
View original file
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const FileDecider = () => {
|
||||
const { mimeType, setIsNotSupported } = React.useContext(FilePreviewContext);
|
||||
|
||||
if (mimeType == "application/pdf") {
|
||||
return <PdfPreview />;
|
||||
} else if (mimeType.startsWith("video/")) {
|
||||
return <VideoPreview />;
|
||||
} else if (mimeType.startsWith("image/")) {
|
||||
return <ImagePreview />;
|
||||
} else if (mimeType.startsWith("audio/")) {
|
||||
return <AudioPreview />;
|
||||
} else if (mimeType.startsWith("text/")) {
|
||||
return <TextPreview />;
|
||||
} else {
|
||||
setIsNotSupported(true);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const AudioPreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10} style={{ width: "100%" }}>
|
||||
<audio controls style={{ width: "100%" }}>
|
||||
<source
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
</audio>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
<video width="100%" controls>
|
||||
<source
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
</video>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
alt={`${fileId}_preview`}
|
||||
width="100%"
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TextPreview = () => {
|
||||
const { shareId, fileId } = React.useContext(FilePreviewContext);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/shares/${shareId}/files/${fileId}?download=false`).then((res) => {
|
||||
console.log(res.data);
|
||||
setText(res.data);
|
||||
});
|
||||
}, [shareId, fileId]);
|
||||
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10} style={{ width: "100%" }}>
|
||||
<Text sx={{ whiteSpace: "pre-wrap" }} size="sm">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const PdfPreview = () => {
|
||||
const { shareId, fileId } = React.useContext(FilePreviewContext);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const UnSupportedFile = () => {
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10}>
|
||||
<Title order={3}>Preview not supported</Title>
|
||||
<Text>
|
||||
A preview for thise file type is unsupported. Please download the file
|
||||
to view it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
|
||||
import { ActionIcon, Button, Stack, TextInput } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
@@ -14,11 +14,7 @@ const showCompletedReverseShareModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Reverse share link</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: "Reverse share link",
|
||||
children: <Body link={link} getReverseShares={getReverseShares} />,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
@@ -25,7 +24,7 @@ const showCreateReverseShareModal = (
|
||||
getReverseShares: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Create reverse share</Title>,
|
||||
title: "Create reverse share",
|
||||
children: (
|
||||
<Body
|
||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import mime from "mime-types";
|
||||
import { FileMetaData } from "../../../types/File.type";
|
||||
import FilePreview from "../FilePreview";
|
||||
|
||||
const showFilePreviewModal = (
|
||||
shareId: string,
|
||||
file: FileMetaData,
|
||||
modals: ModalsContextProps
|
||||
) => {
|
||||
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
|
||||
return modals.openModal({
|
||||
size: "xl",
|
||||
title: file.name,
|
||||
children: (
|
||||
<FilePreview shareId={shareId} fileId={file.id} mimeType={mimeType} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export default showFilePreviewModal;
|
||||
@@ -1,57 +1,52 @@
|
||||
import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, PasswordInput, Stack, Text } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useState } from "react";
|
||||
|
||||
const showEnterPasswordModal = (
|
||||
modals: ModalsContextProps,
|
||||
submitCallback: any
|
||||
submitCallback: (password: string) => Promise<void>
|
||||
) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<>
|
||||
<Title order={4}>Password required</Title>
|
||||
<Text size="sm">
|
||||
This access this share please enter the password for the share.
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
title: "Password required",
|
||||
children: <Body submitCallback={submitCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ submitCallback }: { submitCallback: any }) => {
|
||||
const Body = ({
|
||||
submitCallback,
|
||||
}: {
|
||||
submitCallback: (password: string) => Promise<void>;
|
||||
}) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordWrong, setPasswordWrong] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="Password"
|
||||
error={passwordWrong && "Wrong password"}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
submitCallback(password)
|
||||
.then((res: any) => res)
|
||||
.catch((e: any) => {
|
||||
const error = e.response.data.message;
|
||||
if (error == "Wrong password") {
|
||||
setPasswordWrong(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
<Stack align="stretch">
|
||||
<Text size="sm">
|
||||
This access this share please enter the password for the share.
|
||||
</Text>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCallback(password);
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="Password"
|
||||
error={passwordWrong && "Wrong password"}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -12,7 +12,7 @@ const showErrorModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>{title}</Title>,
|
||||
title: title,
|
||||
|
||||
children: <Body text={text} />,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Button, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
@@ -24,11 +17,7 @@ const showCompletedUploadModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Share ready</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: "Share ready",
|
||||
children: <Body share={share} appUrl={appUrl} />,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const showCreateUploadModal = (
|
||||
uploadCallback: (createShare: CreateShare) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
title: "Share",
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
options={options}
|
||||
|
||||
@@ -9,6 +9,7 @@ import showEnterPasswordModal from "../../../components/share/showEnterPasswordM
|
||||
import showErrorModal from "../../../components/share/showErrorModal";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { Share as ShareType } from "../../../types/share.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
@@ -28,12 +29,15 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
getFiles();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.response.data.error == "share_max_views_exceeded") {
|
||||
const { error } = e.response.data;
|
||||
if (error == "share_max_views_exceeded") {
|
||||
showErrorModal(
|
||||
modals,
|
||||
"Visitor limit exceeded",
|
||||
"The visitor limit from this share has been exceeded."
|
||||
);
|
||||
} else {
|
||||
toast.axiosError(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -85,7 +89,12 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
|
||||
</Group>
|
||||
|
||||
<FileList files={share?.files} share={share!} isLoading={!share} />
|
||||
<FileList
|
||||
files={share?.files}
|
||||
setShare={setShare}
|
||||
share={share!}
|
||||
isLoading={!share}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useState } from "react";
|
||||
|
||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { shareId, fileId } = context.params!;
|
||||
|
||||
const mimeType = context.query.type as string;
|
||||
|
||||
return {
|
||||
props: { shareId, fileId, mimeType },
|
||||
};
|
||||
}
|
||||
|
||||
const UnSupportedFile = () => {
|
||||
return (
|
||||
<Center style={{ height: "70vh" }}>
|
||||
<Stack align="center" spacing={10}>
|
||||
<Title order={3}>Preview not supported</Title>
|
||||
<Text>
|
||||
A preview for thise file type is unsupported. Please download the file
|
||||
to view it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const FilePreview = ({
|
||||
shareId,
|
||||
fileId,
|
||||
mimeType,
|
||||
}: {
|
||||
shareId: string;
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
}) => {
|
||||
const [isNotSupported, setIsNotSupported] = useState(false);
|
||||
|
||||
if (isNotSupported) return <UnSupportedFile />;
|
||||
|
||||
if (mimeType == "application/pdf") {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
|
||||
}
|
||||
return null;
|
||||
} else if (mimeType.startsWith("video/")) {
|
||||
return (
|
||||
<video
|
||||
width="100%"
|
||||
controls
|
||||
onError={() => {
|
||||
setIsNotSupported(true);
|
||||
}}
|
||||
>
|
||||
<source src={`/api/shares/${shareId}/files/${fileId}?download=false`} />
|
||||
</video>
|
||||
);
|
||||
} else if (mimeType.startsWith("image/")) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
onError={() => {
|
||||
setIsNotSupported(true);
|
||||
}}
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
alt={`${fileId}_preview`}
|
||||
width="100%"
|
||||
/>
|
||||
);
|
||||
} else if (mimeType.startsWith("audio/")) {
|
||||
return (
|
||||
<Center style={{ height: "70vh" }}>
|
||||
<Stack align="center" spacing={10} style={{ width: "100%" }}>
|
||||
<audio
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
onError={() => {
|
||||
setIsNotSupported(true);
|
||||
}}
|
||||
>
|
||||
<source
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
/>
|
||||
</audio>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
} else {
|
||||
return <UnSupportedFile />;
|
||||
}
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -59,7 +59,10 @@ const Upload = ({
|
||||
|
||||
setFileProgress(1);
|
||||
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
let chunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
// If the file is 0 bytes, we still need to upload 1 chunk
|
||||
if (chunks == 0) chunks++;
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
|
||||
const from = chunkIndex * chunkSize;
|
||||
@@ -78,7 +81,7 @@ const Upload = ({
|
||||
name: file.name,
|
||||
},
|
||||
chunkIndex,
|
||||
Math.ceil(file.size / chunkSize)
|
||||
chunks
|
||||
)
|
||||
.then((response) => {
|
||||
fileId = response.id;
|
||||
|
||||
@@ -53,7 +53,7 @@ const isShareIdAvailable = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
const doesFileSupportPreview = (fileName: string) => {
|
||||
const mimeType = mime.contentType(fileName);
|
||||
const mimeType = (mime.contentType(fileName) || "").split(";")[0];
|
||||
|
||||
if (!mimeType) return false;
|
||||
|
||||
@@ -61,6 +61,7 @@ const doesFileSupportPreview = (fileName: string) => {
|
||||
mimeType.startsWith("video/"),
|
||||
mimeType.startsWith("image/"),
|
||||
mimeType.startsWith("audio/"),
|
||||
mimeType.startsWith("text/"),
|
||||
mimeType == "application/pdf",
|
||||
];
|
||||
|
||||
|
||||
@@ -16,4 +16,14 @@ export default <MantineThemeOverride>{
|
||||
],
|
||||
},
|
||||
primaryColor: "victoria",
|
||||
components: {
|
||||
Modal: {
|
||||
styles: (theme) => ({
|
||||
title: {
|
||||
fontSize: theme.fontSizes.lg,
|
||||
fontWeight: 700,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"scripts": {
|
||||
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
||||
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
||||
|
||||
Reference in New Issue
Block a user