Attachments
This commit is contained in:
parent
7dfb2d50c7
commit
f9219d2d96
10 changed files with 185 additions and 33 deletions
1
web/public/static/img/file-app.svg
Normal file
1
web/public/static/img/file-app.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#555555"><g><path d="M0,0h24v24H0V0z" fill="none"/></g><g><g><path d="M17.6,9.48l1.84-3.18c0.16-0.31,0.04-0.69-0.26-0.85c-0.29-0.15-0.65-0.06-0.83,0.22l-1.88,3.24 c-2.86-1.21-6.08-1.21-8.94,0L5.65,5.67c-0.19-0.29-0.58-0.38-0.87-0.2C4.5,5.65,4.41,6.01,4.56,6.3L6.4,9.48 C3.3,11.25,1.28,14.44,1,18h22C22.72,14.44,20.7,11.25,17.6,9.48z M7,15.25c-0.69,0-1.25-0.56-1.25-1.25 c0-0.69,0.56-1.25,1.25-1.25S8.25,13.31,8.25,14C8.25,14.69,7.69,15.25,7,15.25z M17,15.25c-0.69,0-1.25-0.56-1.25-1.25 c0-0.69,0.56-1.25,1.25-1.25s1.25,0.56,1.25,1.25C18.25,14.69,17.69,15.25,17,15.25z"/></g></g></svg>
|
After Width: | Height: | Size: 712 B |
1
web/public/static/img/file-audio.svg
Normal file
1
web/public/static/img/file-audio.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#B300FF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 3l.01 10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4.01 4S14 19.21 14 17V7h4V3h-6zm-1.99 16c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
|
After Width: | Height: | Size: 312 B |
1
web/public/static/img/file-document.svg
Normal file
1
web/public/static/img/file-document.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#00ADFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>
|
After Width: | Height: | Size: 272 B |
1
web/public/static/img/file-image.svg
Normal file
1
web/public/static/img/file-image.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#E30000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>
|
After Width: | Height: | Size: 297 B |
1
web/public/static/img/file-video.svg
Normal file
1
web/public/static/img/file-video.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FF9800"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M4 6.47L5.76 10H20v8H4V6.47M22 4h-4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4z"/></svg>
|
After Width: | Height: | Size: 297 B |
|
@ -90,6 +90,20 @@ export const encodeBase64Url = (s) => {
|
||||||
.replaceAll('=', '');
|
.replaceAll('=', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatShortDateTime = (timestamp) => {
|
||||||
|
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
||||||
|
.format(new Date(timestamp * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
export async function* fetchLinesIterator(fileURL, headers) {
|
export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
const utf8Decoder = new TextDecoder('utf-8');
|
const utf8Decoder = new TextDecoder('utf-8');
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
import {CardActions, CardContent, Link, Stack} from "@mui/material";
|
import {ButtonBase, CardActions, CardContent, Fade, Link, Modal, Stack, styled} from "@mui/material";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {formatMessage, formatTitle, topicShortUrl, unmatchedTags} from "../app/utils";
|
import {formatBytes, formatMessage, formatShortDateTime, formatTitle, topicShortUrl, unmatchedTags} from "../app/utils";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import {Paragraph, VerticallyCenteredContainer} from "./styles";
|
import {Paragraph, VerticallyCenteredContainer} from "./styles";
|
||||||
|
@ -11,6 +11,8 @@ import {useLiveQuery} from "dexie-react-hooks";
|
||||||
import db from "../app/db";
|
import db from "../app/db";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
|
import theme from "./theme";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
const Notifications = (props) => {
|
const Notifications = (props) => {
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
|
@ -25,7 +27,7 @@ const Notifications = (props) => {
|
||||||
const sortedNotifications = Array.from(notifications)
|
const sortedNotifications = Array.from(notifications)
|
||||||
.sort((a, b) => a.time < b.time ? 1 : -1);
|
.sort((a, b) => a.time < b.time ? 1 : -1);
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{marginTop: 3, marginBottom: 3}}>
|
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{sortedNotifications.map(notification =>
|
{sortedNotifications.map(notification =>
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
|
@ -41,19 +43,17 @@ const Notifications = (props) => {
|
||||||
const NotificationItem = (props) => {
|
const NotificationItem = (props) => {
|
||||||
const subscriptionId = props.subscriptionId;
|
const subscriptionId = props.subscriptionId;
|
||||||
const notification = props.notification;
|
const notification = props.notification;
|
||||||
const date = new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
const attachment = notification.attachment;
|
||||||
.format(new Date(notification.time * 1000));
|
const date = formatShortDateTime(notification.time);
|
||||||
const otherTags = unmatchedTags(notification.tags);
|
const otherTags = unmatchedTags(notification.tags);
|
||||||
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
|
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
|
||||||
await db.notifications.delete(notification.id); // FIXME
|
await db.notifications.delete(notification.id); // FIXME
|
||||||
}
|
}
|
||||||
const attachment = notification.attachment;
|
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
|
||||||
const expired = attachment?.expires <= Date.now()/1000;
|
|
||||||
const image = attachment?.type.startsWith("image/") && !expired;
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ minWidth: 275 }}>
|
<Card sx={{ minWidth: 275, padding: 1 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
|
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
|
@ -69,27 +69,11 @@ const NotificationItem = (props) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
|
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
|
||||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{formatMessage(notification)}</Typography>
|
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{formatMessage(notification)}</Typography>
|
||||||
{image && <Box
|
{attachment && <Attachment attachment={attachment}/>}
|
||||||
component="img"
|
|
||||||
src={`${attachment.url}`}
|
|
||||||
loading="lazy"
|
|
||||||
sx={{
|
|
||||||
marginTop: 2,
|
|
||||||
borderRadius: '4px',
|
|
||||||
boxShadow: 2,
|
|
||||||
maxWidth: 1,
|
|
||||||
maxHeight: '400px'
|
|
||||||
}}
|
|
||||||
/>}
|
|
||||||
{attachment && !image &&
|
|
||||||
<Typography>
|
|
||||||
<b>{attachment.name}</b><br/>
|
|
||||||
{attachment.size}, {attachment.expires}
|
|
||||||
</Typography>}
|
|
||||||
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
|
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{attachment &&
|
{attachment && !expired &&
|
||||||
<CardActions>
|
<CardActions sx={{paddingTop: 0}}>
|
||||||
<Button onClick={() => navigator.clipboard.writeText(attachment.url)}>Copy URL</Button>
|
<Button onClick={() => navigator.clipboard.writeText(attachment.url)}>Copy URL</Button>
|
||||||
<Button onClick={() => window.open(attachment.url)}>Open</Button>
|
<Button onClick={() => window.open(attachment.url)}>Open</Button>
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
@ -98,6 +82,151 @@ const NotificationItem = (props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Attachment = (props) => {
|
||||||
|
const attachment = props.attachment;
|
||||||
|
const expired = attachment.expires && attachment.expires < Date.now()/1000;
|
||||||
|
const expires = attachment.expires && attachment.expires > Date.now()/1000;
|
||||||
|
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
|
||||||
|
|
||||||
|
// Unexpired image
|
||||||
|
if (displayableImage) {
|
||||||
|
return <Image attachment={attachment}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: Show box
|
||||||
|
const infos = [];
|
||||||
|
if (attachment.size) {
|
||||||
|
infos.push(formatBytes(attachment.size));
|
||||||
|
}
|
||||||
|
if (expires) {
|
||||||
|
infos.push(`link expires ${formatShortDateTime(attachment.expires)}`);
|
||||||
|
}
|
||||||
|
if (expired) {
|
||||||
|
infos.push(`download link expired`);
|
||||||
|
}
|
||||||
|
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
|
||||||
|
|
||||||
|
// If expired, just show infos without click target
|
||||||
|
if (expired) {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 2,
|
||||||
|
padding: 1,
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}>
|
||||||
|
<Icon type={attachment.type}/>
|
||||||
|
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||||
|
<b>{attachment.name}</b>
|
||||||
|
{maybeInfoText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not expired
|
||||||
|
return (
|
||||||
|
<ButtonBase sx={{
|
||||||
|
marginTop: 2,
|
||||||
|
}}>
|
||||||
|
<Link
|
||||||
|
href={attachment.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
underline="none"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 1,
|
||||||
|
borderRadius: '4px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type={attachment.type}/>
|
||||||
|
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||||
|
<b>{attachment.name}</b>
|
||||||
|
{maybeInfoText}
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Image = (props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`${props.attachment.url}`}
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
sx={{
|
||||||
|
marginTop: 2,
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: 2,
|
||||||
|
width: 1,
|
||||||
|
maxHeight: '400px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Fade in={open}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`${props.attachment.url}`}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
maxWidth: 1,
|
||||||
|
maxHeight: 1,
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = (props) => {
|
||||||
|
const type = props.type;
|
||||||
|
let imageFile;
|
||||||
|
if (!type) {
|
||||||
|
imageFile = 'file-document.svg';
|
||||||
|
} else if (type.startsWith('image/')) {
|
||||||
|
imageFile = 'file-image.svg';
|
||||||
|
} else if (type.startsWith('video/')) {
|
||||||
|
imageFile = 'file-video.svg';
|
||||||
|
} else if (type.startsWith('audio/')) {
|
||||||
|
imageFile = 'file-audio.svg';
|
||||||
|
} else if (type === "application/vnd.android.package-archive") {
|
||||||
|
imageFile = 'file-app.svg';
|
||||||
|
} else {
|
||||||
|
imageFile = 'file-document.svg';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`static/img/${imageFile}`}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const NothingHereYet = (props) => {
|
const NothingHereYet = (props) => {
|
||||||
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||||
|
|
|
@ -35,7 +35,7 @@ import DialogActions from "@mui/material/DialogActions";
|
||||||
|
|
||||||
const Preferences = (props) => {
|
const Preferences = (props) => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{marginTop: 3, marginBottom: 3}}>
|
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Notifications/>
|
<Notifications/>
|
||||||
<DefaultServer/>
|
<DefaultServer/>
|
||||||
|
|
|
@ -14,5 +14,5 @@ export const VerticallyCenteredContainer = styled(Container)({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignContent: 'center',
|
alignContent: 'center',
|
||||||
color: theme.palette.body.main
|
color: theme.palette.text.primary
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,9 +12,6 @@ const theme = createTheme({
|
||||||
error: {
|
error: {
|
||||||
main: red.A400,
|
main: red.A400,
|
||||||
},
|
},
|
||||||
body: {
|
|
||||||
main: '#444',
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiListItemIcon: {
|
MuiListItemIcon: {
|
||||||
|
@ -24,6 +21,13 @@ const theme = createTheme({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MuiBackdrop: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue