Compare commits
No commits in common. "main" and "9b23de08276e1e7fac28b8f52b8e533ccc3ee14a" have entirely different histories.
main
...
9b23de0827
2
.gitignore
vendored
2
.gitignore
vendored
@ -32,5 +32,3 @@ yarn-error.log*
|
|||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
*.sw*
|
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": {
|
|
||||||
"AWS": [
|
|
||||||
"*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Action": [
|
|
||||||
"s3:GetObject"
|
|
||||||
],
|
|
||||||
"Resource": [
|
|
||||||
"arn:aws:s3::twill-wedding-photos/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Box } from '@chakra-ui/react';
|
|
||||||
import { decode } from "blurhash";
|
|
||||||
|
|
||||||
function BlurrableImage({ src, thumbSrc, blurHash, isImage, isVideo, onClick }) {
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const canvasRef = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (blurHash) {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const imageData = ctx.createImageData(200, 200);
|
|
||||||
const pixels = decode(blurHash, 200, 200);
|
|
||||||
imageData.data.set(pixels);
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}
|
|
||||||
}, [blurHash]);
|
|
||||||
|
|
||||||
if (isImage || !isVideo)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<img src={src} onLoad={e => setLoaded(true)} style={{display:'none'}} alt='Loader' />
|
|
||||||
{loaded &&
|
|
||||||
<Box w="100%" h="10" bg="blue.500" bg={`url(${src})`} bgPosition='center' bgSize='cover' onClick={onClick} />
|
|
||||||
}
|
|
||||||
{blurHash &&
|
|
||||||
<canvas width={200} height={200} ref={canvasRef} style={{display: !loaded ? 'block' : 'none'}}/>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
if (isVideo)
|
|
||||||
return (
|
|
||||||
<Box w="100%" h="10" bg="blue.500" bg={`url(${thumbSrc})`} bgPosition='center' bgSize='cover' onClick={onClick} d='flex' flexDirection='vertical'>
|
|
||||||
<div style={{fontSize: 20}}>▶️</div>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BlurrableImage;
|
|
208
lib/Header.js
208
lib/Header.js
@ -1,208 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Box, Button } from "@chakra-ui/react"
|
|
||||||
import { AddIcon } from '@chakra-ui/icons';
|
|
||||||
import { encode } from "blurhash";
|
|
||||||
import useStore from './store';
|
|
||||||
|
|
||||||
function Header() {
|
|
||||||
const uploader = useRef(null);
|
|
||||||
const { ownerToken, firstName, loggedIn, setLoggedIn, authToken, setAuthToken, addPhoto, setIsUploading, myPhotos, setMyPhotos } = useStore();
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
if (window.confirm('Really logout?')) {
|
|
||||||
setAuthToken('');
|
|
||||||
setLoggedIn(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeImage = file => {
|
|
||||||
if (window.File && window.FileReader && window.FileList && window.Blob) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
var img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
var canvas = document.createElement("canvas");
|
|
||||||
var ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
var MAX_WIDTH = 400;
|
|
||||||
var MAX_HEIGHT = 400;
|
|
||||||
var width = img.width;
|
|
||||||
var height = img.height;
|
|
||||||
|
|
||||||
if (width > height) {
|
|
||||||
if (width > MAX_WIDTH) {
|
|
||||||
height *= MAX_WIDTH / width;
|
|
||||||
width = MAX_WIDTH;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (height > MAX_HEIGHT) {
|
|
||||||
width *= MAX_HEIGHT / height;
|
|
||||||
height = MAX_HEIGHT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
|
||||||
const imageData = ctx.getImageData(0, 0, width, height);
|
|
||||||
const hash = encode(imageData.data, imageData.width, imageData.height, 4, 4);
|
|
||||||
const dataurl = canvas.toDataURL(file.type);
|
|
||||||
|
|
||||||
canvas.toBlob(function(blob) {
|
|
||||||
const resizedFile = new File([blob], file.name);
|
|
||||||
console.log(resizedFile);
|
|
||||||
resolve({ resizedFile, blurHash: hash });
|
|
||||||
}, 'image/jpeg', 1);
|
|
||||||
}
|
|
||||||
img.src = e.target.result;
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('The File APIs are not fully supported in this browser.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImageData = file => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
var image = new Image;
|
|
||||||
const myPromise = new Promise((resolve, reject) => {
|
|
||||||
image.onload = () => {
|
|
||||||
canvas.width = image.width;
|
|
||||||
canvas.height = image.height;
|
|
||||||
context.drawImage(image, 0, 0);
|
|
||||||
resolve(context.getImageData(0, 0, image.width, image.height));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
image.src = URL.createObjectURL(file);
|
|
||||||
return myPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVideoCover = (file, seekTo = 0.0) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// load the file to a video player
|
|
||||||
const videoPlayer = document.createElement('video');
|
|
||||||
videoPlayer.setAttribute('src', URL.createObjectURL(file));
|
|
||||||
videoPlayer.load();
|
|
||||||
videoPlayer.addEventListener('error', (ex) => {
|
|
||||||
reject("error when loading video file", ex);
|
|
||||||
});
|
|
||||||
// load metadata of the video to get video duration and dimensions
|
|
||||||
videoPlayer.addEventListener('loadedmetadata', () => {
|
|
||||||
// seek to user defined timestamp (in seconds) if possible
|
|
||||||
if (videoPlayer.duration < seekTo) {
|
|
||||||
reject("video is too short.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// delay seeking or else 'seeked' event won't fire on Safari
|
|
||||||
setTimeout(() => {
|
|
||||||
videoPlayer.currentTime = seekTo;
|
|
||||||
}, 200);
|
|
||||||
// extract video thumbnail once seeking is complete
|
|
||||||
videoPlayer.addEventListener('seeked', () => {
|
|
||||||
console.log('video is now paused at %ss.', seekTo);
|
|
||||||
// define a canvas to have the same dimension as the video
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = videoPlayer.videoWidth;
|
|
||||||
canvas.height = videoPlayer.videoHeight;
|
|
||||||
// draw the video frame to canvas
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height);
|
|
||||||
// return the canvas image as a blob
|
|
||||||
ctx.canvas.toBlob(
|
|
||||||
blob => {
|
|
||||||
resolve(blob);
|
|
||||||
},
|
|
||||||
"image/jpeg",
|
|
||||||
0.75 /* quality */
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadFile = async (file) => {
|
|
||||||
if (!file) return;
|
|
||||||
const isImage = file.type.split('/')[0] === 'image';
|
|
||||||
const isVideo = file.type.split('/')[0] === 'video';
|
|
||||||
if (!isImage && !isVideo) return alert('Please select an image or video.');
|
|
||||||
|
|
||||||
let blurHash, resizedFile;
|
|
||||||
if (isImage) {
|
|
||||||
const resizeData = await resizeImage(file);
|
|
||||||
blurHash = resizeData.blurHash;
|
|
||||||
resizedFile = resizeData.resizedFile;
|
|
||||||
if (!blurHash || !resizedFile) return;
|
|
||||||
}
|
|
||||||
let videoThumb;
|
|
||||||
if (isVideo) {
|
|
||||||
videoThumb = await getVideoCover(file, 1.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadRequestResponse = await fetch(`/api/upload?name=${file.name}&type=${file.type}`, {
|
|
||||||
headers: { Authorization: authToken },
|
|
||||||
});
|
|
||||||
if (uploadRequestResponse.ok) {
|
|
||||||
const responseJson = await uploadRequestResponse.json();
|
|
||||||
|
|
||||||
// If server offers chance to upload original file, then do so in background
|
|
||||||
if (responseJson.originalUrl) {
|
|
||||||
fetch(responseJson.originalUrl, { method: 'PUT', body: file });
|
|
||||||
}
|
|
||||||
if (responseJson.thumbUrl) {
|
|
||||||
fetch(responseJson.thumbUrl, { method: 'PUT', body: videoThumb });
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadResponse = await fetch(responseJson.url, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: resizedFile || file,
|
|
||||||
});
|
|
||||||
if (uploadResponse.ok) {
|
|
||||||
const storeResponse = await fetch('/api/photos', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: authToken,
|
|
||||||
'Content-Type': 'application/json;charset=utf-8'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ownerToken, firstName, fileName: responseJson.fileName, blurHash, isImage, isVideo, originalFileName: responseJson.originalFileName, thumbFileName: responseJson.thumbFileName }),
|
|
||||||
});
|
|
||||||
const storeResponseJson = await storeResponse.json();
|
|
||||||
if (storeResponseJson?.url) {
|
|
||||||
addPhoto({ firstName, fileName: responseJson.fileName, src: storeResponseJson.url, thumbSrc: storeResponseJson.thumbUrl, blurHash, isImage, isVideo });
|
|
||||||
myPhotos.push(responseJson.fileName);
|
|
||||||
setMyPhotos(myPhotos);
|
|
||||||
window.localStorage.setItem('myPhotos', JSON.stringify({ photos: myPhotos }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChosen = async (event) => {
|
|
||||||
const files = event.target?.files;
|
|
||||||
if (files?.length > 0) {
|
|
||||||
setIsUploading(true);
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
await uploadFile(files[i]);
|
|
||||||
}
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box bg="teal.400" w="100%" p={4} color="white" d='flex' justifyContent='space-between' alignItems='center'>
|
|
||||||
<img src='/logo.png' style={{height: 50}} onClick={logout} />
|
|
||||||
|
|
||||||
{loggedIn && <>
|
|
||||||
<Button onClick={() => uploader?.current?.click()} leftIcon={<AddIcon />} colorScheme="teal" variant="solid">
|
|
||||||
Add a photo or video
|
|
||||||
</Button>
|
|
||||||
<input multiple type='file' style={{display:'none'}} ref={uploader} onChange={handleFileChosen} accept='image/*,video/*' />
|
|
||||||
</>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
@ -1,20 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { Box } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import Header from './Header';
|
|
||||||
|
|
||||||
function Layout( { children }) {
|
|
||||||
return (
|
|
||||||
<Box d='flex' flexDirection='column' style={{height: '100vh'}}>
|
|
||||||
<Head>
|
|
||||||
<title>Twill's Wedding</title>
|
|
||||||
<link rel="icon" href="/favicon.png" />
|
|
||||||
</Head>
|
|
||||||
<Header />
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout;
|
|
63
lib/Login.js
63
lib/Login.js
@ -1,63 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Button, Heading, Text, Input, Alert, AlertIcon } from "@chakra-ui/react"
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import Layout from '../lib/Layout';
|
|
||||||
import useStore from '../lib/store';
|
|
||||||
|
|
||||||
const StyledInput = styled(Input)`
|
|
||||||
::placeholder {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function LoginPage( { } ) {
|
|
||||||
const { firstName, setFirstName, password, setPassword, setLoggedIn, setAuthToken, isLoggingIn, setLoggingIn, loginError, setLoginError } = useStore();
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
if (!firstName) return setLoginError('Please enter your first name.');
|
|
||||||
setLoggingIn(true);
|
|
||||||
setLoginError('');
|
|
||||||
const response = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json;charset=utf-8'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
setLoggingIn(false);
|
|
||||||
const respJson = await response.json();
|
|
||||||
if (respJson.success) {
|
|
||||||
setLoggedIn(true);
|
|
||||||
setAuthToken(respJson.token);
|
|
||||||
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setLoginError('Unable to login. Is your password correct?');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Box pt={30} p={5} bg='teal.400' flex={1} d='flex' flexDirection='column'>
|
|
||||||
<Heading size='lg' mt={30} color='white'>👋 Welcome!</Heading>
|
|
||||||
<Text mt={5} color='white'>Tom and Will are getting married! 🤵🏽🤵🏼♂️ </Text>
|
|
||||||
<Text mt={3} color='white'>We'd love for you to be involved by sharing pictures and videos to help us celebrate. If you have nice (or funny!) pictures of us, or would like to send a video message, or even if you just want to see what others have shared, then <b>login below</b>.</Text>
|
|
||||||
<Heading size='sm' mt={10} mb={1} color='white'>What is your first name?</Heading>
|
|
||||||
<StyledInput autoFocus variant='flushed' color='white' colorScheme='teal' placeholder="Your first name..." size="lg" value={firstName} onChange={e => setFirstName(e.target.value)} />
|
|
||||||
<Heading size='sm' mt={5} mb={1} color='white'>Enter the password</Heading>
|
|
||||||
<StyledInput variant='flushed' color='white' colorScheme='teal' placeholder="Type your password..." size="lg" type='password' value={password} onChange={e => setPassword(e.target.value)} />
|
|
||||||
{loginError &&
|
|
||||||
<Alert status="error" mt={5}>
|
|
||||||
<AlertIcon /> {loginError}
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
{password?.length > 0 &&
|
|
||||||
<Button isLoading={isLoggingIn} size='lg' colorScheme='teal' mt={10} onClick={login}>Login</Button>
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LoginPage;
|
|
37
lib/store.js
37
lib/store.js
@ -1,37 +0,0 @@
|
|||||||
import create from 'zustand'
|
|
||||||
|
|
||||||
const useStore = create(set => ({
|
|
||||||
loggedIn: false,
|
|
||||||
setLoggedIn: (loggedIn) => set(state => ({ loggedIn })),
|
|
||||||
authToken: '',
|
|
||||||
setAuthToken: (authToken) => set(state => ({ authToken })),
|
|
||||||
ownerToken: '',
|
|
||||||
setOwnerToken: (ownerToken) => set(state => ({ ownerToken })),
|
|
||||||
isLoggingIn: false,
|
|
||||||
setLoggingIn: (isLoggingIn) => set(state => ({ isLoggingIn })),
|
|
||||||
loginError: '',
|
|
||||||
setLoginError: (loginError) => set(state => ({ loginError })),
|
|
||||||
firstName: '',
|
|
||||||
setFirstName: (firstName) => set(state => ({ firstName })),
|
|
||||||
password: '',
|
|
||||||
setPassword: (password) => set(state => ({ password })),
|
|
||||||
isUploading: false,
|
|
||||||
uploadingCount: 0,
|
|
||||||
setIsUploading: (isUploading) => set(state => ({ isUploading, uploadingCount: state.uploadingCount + isUploading ? 1 : -1 })),
|
|
||||||
selectedPhotoIndex: -1,
|
|
||||||
setSelectedPhotoIndex: (index) => set(state => ({ selectedPhotoIndex: index })),
|
|
||||||
myPhotos: [],
|
|
||||||
setMyPhotos: myPhotos => set(state => ({ myPhotos })),
|
|
||||||
photos: [],
|
|
||||||
setPhotos: (photos) => set(state => ({ photos })),
|
|
||||||
addPhoto: (photo) => set(state => {
|
|
||||||
const photos = state.photos;
|
|
||||||
photos.splice(0, 0, photo);
|
|
||||||
return { photos };
|
|
||||||
}),
|
|
||||||
nextPhoto: null,
|
|
||||||
setNextPhoto: (nextPhoto) => set(state => ({ nextPhoto })),
|
|
||||||
endReached: false,
|
|
||||||
setEndReached: (endReached) => set(state => ({ endReached })),
|
|
||||||
}));
|
|
||||||
export default useStore;
|
|
4854
package-lock.json
generated
Normal file
4854
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -10,20 +10,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.12.0",
|
"@aws-sdk/client-s3": "^3.12.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.12.0",
|
"@aws-sdk/s3-request-presigner": "^3.12.0",
|
||||||
"@chakra-ui/icons": "^1.0.9",
|
|
||||||
"@chakra-ui/react": "^1.5.0",
|
|
||||||
"@emotion/react": "^11",
|
|
||||||
"@emotion/styled": "^11",
|
|
||||||
"blurhash": "^1.1.3",
|
|
||||||
"faunadb": "^4.1.3",
|
"faunadb": "^4.1.3",
|
||||||
"framer-motion": "^4",
|
|
||||||
"jsonwebtoken": "^8.5.1",
|
|
||||||
"next": "10.1.3",
|
"next": "10.1.3",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"uuid": "^8.3.2"
|
||||||
"styled-components": "^5.3.0",
|
|
||||||
"uuid": "^8.3.2",
|
|
||||||
"zustand": "^3.4.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
pages/.index.js.swp
Normal file
BIN
pages/.index.js.swp
Normal file
Binary file not shown.
@ -1,11 +1,7 @@
|
|||||||
import { ChakraProvider } from "@chakra-ui/react"
|
import '../styles/globals.css'
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
function MyApp({ Component, pageProps }) {
|
||||||
return (
|
return <Component {...pageProps} />
|
||||||
<ChakraProvider>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</ChakraProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyApp
|
export default MyApp
|
||||||
|
BIN
pages/api/.photos.js.swp
Normal file
BIN
pages/api/.photos.js.swp
Normal file
Binary file not shown.
BIN
pages/api/.upload.js.swp
Normal file
BIN
pages/api/.upload.js.swp
Normal file
Binary file not shown.
@ -1,17 +0,0 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
|
||||||
if (req.method == 'POST') {
|
|
||||||
const { password } = req.body;
|
|
||||||
if (password === process.env.PUBLIC_PASSWORD) {
|
|
||||||
const token = jwt.sign({ audience: 'public' }, process.env.JWT_SECRET, { expiresIn: 2592000 });
|
|
||||||
res.status(200).json({ success: true, token });
|
|
||||||
} else if (password === process.env.PRIVATE_PASSWORD) {
|
|
||||||
const token = jwt.sign({ audience: 'private' }, process.env.JWT_SECRET, { expiresIn: 2592000 });
|
|
||||||
res.status(200).json({ success: true, token });
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
res.status(401).json({ success: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,97 +1,35 @@
|
|||||||
import React from 'react';
|
|
||||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import faunadb from 'faunadb';
|
import faunadb from 'faunadb';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
const q = faunadb.query;
|
const q = faunadb.query;
|
||||||
const s3 = new S3Client({
|
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.SECRET_ACCESS_KEY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function getAudience(req, res) {
|
|
||||||
if (req.headers['authorization']) {
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(req.headers['authorization'], process.env.JWT_SECRET);
|
|
||||||
return decoded.audience;
|
|
||||||
} catch(err) {
|
|
||||||
console.log(err);
|
|
||||||
res.status(403).json({ message: 'Invalid token' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else res.status(403).json({ message: 'Authorization required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const audience = getAudience(req, res);
|
|
||||||
if (!audience) return;
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const itemsPerPage = 18;
|
const s3 = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
const params = req.query;
|
});
|
||||||
const next = req.query && parseInt(req.query.next);
|
|
||||||
|
|
||||||
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET })
|
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET })
|
||||||
const helper = await client.query(
|
var helper = await client.paginate(
|
||||||
q.Paginate(
|
q.Match(
|
||||||
q.Match(
|
q.Index('all_photos')
|
||||||
q.Index("reverse_photos")
|
|
||||||
),
|
|
||||||
{
|
|
||||||
size: itemsPerPage,
|
|
||||||
after: next ? [next] : undefined,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const photos = [];
|
||||||
const baseUrl = `https://${process.env.S3_BUCKET}.eu-central-1.linodeobjects.com/`;
|
await helper.map(ref => {
|
||||||
const photos = helper.data.map(photo => ({
|
return q.Get(ref)
|
||||||
ts: photo[0],
|
}).each(page => {
|
||||||
audience: photo[1],
|
page.forEach(photo => {
|
||||||
firstName: photo[2],
|
photos.push(photo);
|
||||||
fileName: photo[3],
|
});
|
||||||
src: `${baseUrl}${photo[3]}`,
|
});
|
||||||
origianlFileName: photo[4],
|
const fileNames = await Promise.all(photos.map(photo => {
|
||||||
originalSrc: photo[4] ? `${baseUrl}${photo[4]}` : null,
|
const command = new GetObjectCommand({
|
||||||
thumbFileName: photo[5],
|
Bucket: process.env.S3_BUCKET,
|
||||||
thumbSrc: photo[5] ? `${baseUrl}${photo[5]}` : null,
|
Key: photo.data.fileName,
|
||||||
blurHash: photo[6],
|
});
|
||||||
isImage: photo[7],
|
return getSignedUrl(s3, command);
|
||||||
isVideo: photo[8],
|
|
||||||
refId: photo[9].id,
|
|
||||||
}));
|
}));
|
||||||
res.status(200).json({ photos, next: helper.after[0], end: !helper.after});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method == 'POST') {
|
res.status(200).json({ photos: fileNames });
|
||||||
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET });
|
|
||||||
const { ownerToken, firstName, fileName, originalFileName, thumbFileName, blurHash, isImage, isVideo } = req.body;
|
|
||||||
const createP = await client.query(
|
|
||||||
q.Create(
|
|
||||||
q.Collection('photos'),
|
|
||||||
{ data: { audience, ownerToken, firstName, fileName, originalFileName, thumbFileName, blurHash, isImage, isVideo } }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
res.status(200).json({ url: `https://${process.env.S3_BUCKET}.eu-central-1.linodeobjects.com/${fileName}`, thumbUrl: `https://${process.env.S3_BUCKET}.eu-central-1.linodeobjects.com/${thumbFileName}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method == 'DELETE') {
|
|
||||||
const { ownerToken, id } = req.query;
|
|
||||||
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET });
|
|
||||||
const result = await client.query(
|
|
||||||
q.Get(q.Ref(q.Collection('photos'), id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result && result.data.ownerToken && ownerToken && ownerToken === result.data.ownerToken) {
|
|
||||||
await client.query(
|
|
||||||
q.Delete(q.Ref(q.Collection('photos'), id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
res.status(200).json({ deleted: id });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,71 +1,33 @@
|
|||||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import jwt from 'jsonwebtoken';
|
import faunadb from 'faunadb';
|
||||||
|
const q = faunadb.query;
|
||||||
const s3 = new S3Client({
|
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.SECRET_ACCESS_KEY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const headers = req.headers;
|
const s3 = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
let audience;
|
});
|
||||||
if (req.headers['authorization']) {
|
const fileName = uuidv4() + '.jpg';
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(req.headers['authorization'], process.env.JWT_SECRET);
|
|
||||||
audience = decoded.audience;
|
|
||||||
} catch(err) {
|
|
||||||
return res.status(403).json({ message: 'Invalid token' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!audience) return res.status(403).json({ message: 'Authorization required' });
|
|
||||||
|
|
||||||
const params = req.query;
|
|
||||||
const extension = params.name.split('.').pop();
|
|
||||||
const id = uuidv4();
|
|
||||||
const fileName = id + '.' + extension;
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: process.env.S3_BUCKET,
|
Bucket: process.env.S3_BUCKET,
|
||||||
Key: fileName,
|
Key: fileName,
|
||||||
ACL: 'public',
|
ACL: 'public',
|
||||||
ContentType: params.type,
|
ContentType: 'image/jpg',
|
||||||
});
|
});
|
||||||
const url = await getSignedUrl(s3, command);
|
const url = await getSignedUrl(s3, command);
|
||||||
const returnData = { url, fileName };
|
res.status(200).json({ url, fileName });
|
||||||
|
}
|
||||||
|
|
||||||
// If image return upload URL to optionally upload full-res version
|
if (req.method == 'POST') {
|
||||||
if (params.type.split('/')[0] === 'image') {
|
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET });
|
||||||
const fullFileName = id + '_original.' + extension;
|
const createP = await client.query(
|
||||||
const command = new PutObjectCommand({
|
q.Create(
|
||||||
Bucket: process.env.S3_BUCKET,
|
q.Collection('photos'),
|
||||||
Key: fullFileName,
|
{ data: { fileName: req.body.fileName } }
|
||||||
ACL: 'public',
|
)
|
||||||
ContentType: params.type,
|
);
|
||||||
});
|
res.status(200);
|
||||||
const fullUrl = await getSignedUrl(s3, command);
|
|
||||||
returnData.originalUrl = fullUrl;
|
|
||||||
returnData.originalFileName = fullFileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.type.split('/')[0] === 'video') {
|
|
||||||
const thumbFileName = id + '_thumb.jpg';
|
|
||||||
const command = new PutObjectCommand({
|
|
||||||
Bucket: process.env.S3_BUCKET,
|
|
||||||
Key: thumbFileName,
|
|
||||||
ACL: 'public',
|
|
||||||
ContentType: params.type,
|
|
||||||
});
|
|
||||||
const thumbUrl = await getSignedUrl(s3, command);
|
|
||||||
returnData.thumbUrl = thumbUrl;
|
|
||||||
returnData.thumbFileName = thumbFileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(returnData);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
214
pages/index.js
214
pages/index.js
@ -1,159 +1,79 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import Head from 'next/head'
|
||||||
import { Text, Grid, GridItem, Box, AspectRatio, Progress, CircularProgress } from '@chakra-ui/react';
|
import faunadb from 'faunadb';
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
|
||||||
import Layout from '../lib/Layout';
|
|
||||||
import LoginPage from '../lib/Login';
|
|
||||||
import BlurrableImage from '../lib/BlurrableImage';
|
|
||||||
import useStore from '../lib/store';
|
|
||||||
|
|
||||||
export default function Home({ }) {
|
const q = faunadb.query;
|
||||||
const { firstName, setFirstName, loggedIn, setLoggedIn, authToken, setAuthToken, ownerToken, setOwnerToken, isUploading, uploadingCount, selectedPhotoIndex, setSelectedPhotoIndex, photos, setPhotos, addPhoto, myPhotos, setMyPhotos, nextPhoto, setNextPhoto, endReached, setEndReached } = useStore();
|
|
||||||
|
|
||||||
const getPhotos = async (refresh) => {
|
export default function Home({ hello, customers }) {
|
||||||
if (loggedIn && authToken) {
|
const [photos, setPhotos] = useState([]);
|
||||||
const response = await fetch(`/api/photos${(!refresh && nextPhoto) ? `?next=${nextPhoto}` : ''}`, {
|
|
||||||
headers: {
|
const getStuff = async (event) => {
|
||||||
Authorization: authToken,
|
const file = event.target?.files[0];
|
||||||
},
|
console.log(file);
|
||||||
|
const response = await fetch('/api/upload');
|
||||||
|
if (response.ok) { // if HTTP-status is 200-299
|
||||||
|
const responseJson = await response.json();
|
||||||
|
const uploadResponse = await fetch(responseJson.url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: file,
|
||||||
});
|
});
|
||||||
const json = await response.json();
|
if (uploadResponse.ok) {
|
||||||
setPhotos(refresh ? json.photos : photos.concat(json.photos));
|
const storeResponse = await fetch('/api/upload', {
|
||||||
if (json.next) setNextPhoto(json.next);
|
method: 'POST',
|
||||||
setEndReached(json.end);
|
headers: {
|
||||||
}
|
'Content-Type': 'application/json;charset=utf-8'
|
||||||
}
|
},
|
||||||
|
body: JSON.stringify({ fileName: responseJson.fileName }),
|
||||||
const deletePhoto = async (e, photo) => {
|
});
|
||||||
e.stopPropagation();
|
|
||||||
if (window.confirm('Really delete this photo?')) {
|
|
||||||
const response = await fetch(`/api/photos?id=${photo.refId}&ownerToken=${ownerToken}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Authorization: authToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const json = await response.json();
|
|
||||||
if (json?.deleted) {
|
|
||||||
setSelectedPhotoIndex(-1);
|
|
||||||
setPhotos(photos.filter(p => p.refId !== photo.refId));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time function: load photo URLs from local storage on app-load
|
const getPhotos = async () => {
|
||||||
useEffect(() => {
|
const response = await fetch('/api/photos');
|
||||||
const myPhotosString = window.localStorage.getItem('myPhotos');
|
const json = await response.json();
|
||||||
if (myPhotosString) {
|
setPhotos(json.photos);
|
||||||
const unencodedString = JSON.parse(myPhotosString);
|
console.log(json);
|
||||||
if (unencodedString?.photos) {
|
|
||||||
setMyPhotos(unencodedString.photos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let storedOwnerToken = window.localStorage.getItem('ownerToken');
|
|
||||||
if (!storedOwnerToken) {
|
|
||||||
storedOwnerToken = uuidv4();
|
|
||||||
window.localStorage.setItem('ownerToken', storedOwnerToken);
|
|
||||||
}
|
|
||||||
setOwnerToken(storedOwnerToken);
|
|
||||||
const storedAuthToken = window.localStorage.getItem('authToken');
|
|
||||||
if (storedAuthToken) {
|
|
||||||
setLoggedIn(true);
|
|
||||||
setAuthToken(storedAuthToken);
|
|
||||||
}
|
|
||||||
const storedFirstName = window.localStorage.getItem('firstName');
|
|
||||||
if (storedFirstName) {
|
|
||||||
setFirstName(storedFirstName);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
getPhotos();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem('authToken', authToken);
|
|
||||||
}, [authToken]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem('firstName', firstName);
|
|
||||||
}, [firstName]);
|
|
||||||
|
|
||||||
// If login status changes then re-fetch photos
|
|
||||||
useEffect(getPhotos, [loggedIn, authToken]);
|
|
||||||
|
|
||||||
if (!loggedIn) return <LoginPage />;
|
|
||||||
|
|
||||||
const selectedPhoto = photos && photos[selectedPhotoIndex];
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<div>
|
||||||
<Box flex={1}>
|
<Head>
|
||||||
{isUploading &&
|
<title>Create Next App</title>
|
||||||
<Progress size="sm" isIndeterminate colorScheme='blue' />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
}
|
</Head>
|
||||||
{/*
|
<h1>Wedding</h1>
|
||||||
<Grid templateColumns="repeat(3, 1fr)" gap={0.5} flex={1}>*/}
|
<h2>{hello}</h2>
|
||||||
<InfiniteScroll
|
<p>{customers}</p>
|
||||||
dataLength={photos.length}
|
<button onClick={getPhotos}>Hi</button>
|
||||||
next={fetchData}
|
<input type="file" onChange={getStuff} />
|
||||||
hasMore={!endReached}
|
{photos.map(photo =>
|
||||||
loader={<h4 style={{textAlign: 'center'}}>Loading...</h4>}
|
<img src={photo} />
|
||||||
endMessage={
|
)}
|
||||||
<p style={{ textAlign: 'center' }}>
|
</div>
|
||||||
<b>Yay! You have seen it all</b>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
style={{display: 'grid', gridGap: 2, gridTemplateColumns: 'repeat(3, 1fr)'}}
|
|
||||||
>
|
|
||||||
{photos.map((photo, i) =>
|
|
||||||
<AspectRatio maxW="100%" ratio={1} key={photo.src}>
|
|
||||||
<BlurrableImage {...photo} onClick={e => setSelectedPhotoIndex(i)}/>
|
|
||||||
</AspectRatio>
|
|
||||||
)}
|
|
||||||
</InfiniteScroll>
|
|
||||||
{/*</Grid>*/}
|
|
||||||
|
|
||||||
{selectedPhotoIndex > -1 &&
|
|
||||||
<div style={{position: 'fixed', top: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.8)', display: 'flex', flexDirection: 'column', justifyContent: 'center'}} onClick={e => setSelectedPhotoIndex(-1)}>
|
|
||||||
{(selectedPhoto.isImage || !selectedPhoto.isVideo) &&
|
|
||||||
<img src={selectedPhoto.src} style={{maxWidth: '100%', maxHeight: '900px', alignSelf: 'center', display: 'block'}} onClick={e => e.stopPropagation()}/>
|
|
||||||
}
|
|
||||||
{selectedPhoto.isVideo &&
|
|
||||||
<video playsInline style={{maxWidth:'100%', maxHeight: '900px', alignSelf: 'center', display: 'block'}} controls autoPlay onClick={e => e.stopPropagation()}>
|
|
||||||
<source src={selectedPhoto.src} />
|
|
||||||
</video>
|
|
||||||
}
|
|
||||||
{selectedPhoto.firstName &&
|
|
||||||
<Text color='white' textAlign='center' fontSize='lg' mt={5}>📸 Added by {selectedPhoto.firstName}</Text>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div style={{position: 'absolute', left: '5px', top: '10px', fontSize: 15, background: 'rgba(0,0,0,0.5)', padding: 4, borderRadius: '50%', width:30, height: 30, textAlign: 'center' }} onClick={e => {setSelectedPhotoIndex(-1);e.stopPropagation();}}>
|
|
||||||
❌
|
|
||||||
</div>
|
|
||||||
{myPhotos?.indexOf(selectedPhoto.fileName) > -1 && selectedPhoto.refId &&
|
|
||||||
<div style={{position: 'absolute', right: '5px', top: '30%', fontSize: 20, background: 'rgba(0,0,0,0.5)', padding: 4, borderRadius: '50%', width:40, height: 40, textAlign: 'center' }} onClick={e => deletePhoto(e, selectedPhoto)}>
|
|
||||||
🚮
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{selectedPhoto.originalSrc &&
|
|
||||||
<a href={selectedPhoto.originalSrc} target="_blank" rel="noopener noreferrer" style={{position: 'absolute', right: '5px', top: '40%', fontSize: 20, background: 'rgba(0,0,0,0.5)', padding: 4, borderRadius: '50%', width:40, height: 40, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
|
|
||||||
🖼
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
{selectedPhotoIndex > 0 &&
|
|
||||||
<div style={{position: 'absolute', left: '5px', top: '45%', fontSize: 20, background: 'rgba(0,0,0,0.5)', padding: 4, borderRadius: '50%', width:40, height: 40, textAlign: 'center' }} onClick={e => {setSelectedPhotoIndex(selectedPhotoIndex - 1);e.stopPropagation();}}>
|
|
||||||
👈
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{selectedPhotoIndex < photos.length - 1&&
|
|
||||||
<div style={{position: 'absolute', right: '5px', top: '45%', fontSize: 20, background: 'rgba(0,0,0,0.5)', padding: 4, borderRadius: '50%', width:40, height: 40, textAlign: 'center' }} onClick={e => {setSelectedPhotoIndex(selectedPhotoIndex + 1);e.stopPropagation();}}>
|
|
||||||
👉
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getStaticProps(context) {
|
||||||
|
const client = new faunadb.Client({ secret: process.env.FAUNA_SECRET })
|
||||||
|
|
||||||
|
var helper = await client.paginate(
|
||||||
|
q.Match(
|
||||||
|
q.Index('all_customers')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const customers = [];
|
||||||
|
await helper.map(function(ref) {
|
||||||
|
return q.Get(ref)
|
||||||
|
})
|
||||||
|
.each(function(page) {
|
||||||
|
page.forEach(customer => customers.push(customer));
|
||||||
|
});
|
||||||
|
console.log(customers);
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: { hello: 123, }, // will be passed to the page component as props
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
41
pages/new.js
41
pages/new.js
@ -1,41 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import faunadb from 'faunadb';
|
|
||||||
import Layout from '../lib/Layout';
|
|
||||||
|
|
||||||
const q = faunadb.query;
|
|
||||||
|
|
||||||
export default function New({ }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const getStuff = async (event) => {
|
|
||||||
const file = event.target?.files[0];
|
|
||||||
console.log(file);
|
|
||||||
const response = await fetch('/api/upload');
|
|
||||||
if (response.ok) { // if HTTP-status is 200-299
|
|
||||||
const responseJson = await response.json();
|
|
||||||
const uploadResponse = await fetch(responseJson.url, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
if (uploadResponse.ok) {
|
|
||||||
const storeResponse = await fetch('/api/photos', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json;charset=utf-8'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ fileName: responseJson.fileName }),
|
|
||||||
});
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<h1>New</h1>
|
|
||||||
<input type="file" onChange={getStuff} />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 19 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
Before Width: | Height: | Size: 20 KiB |
122
styles/Home.module.css
Normal file
122
styles/Home.module.css
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 5rem 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer img {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a {
|
||||||
|
color: #0070f3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a:hover,
|
||||||
|
.title a:focus,
|
||||||
|
.title a:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.description {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 800px;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 1rem;
|
||||||
|
flex-basis: 45%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus,
|
||||||
|
.card:active {
|
||||||
|
color: #0070f3;
|
||||||
|
border-color: #0070f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
16
styles/globals.css
Normal file
16
styles/globals.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user