Compare commits
24 Commits
william/se
...
main
Author | SHA1 | Date |
---|---|---|
Aditya Thakral | 0548beb3bb | |
Neil Parikh | f16be2231f | |
Dora Su | 8033863a33 | |
Neil Parikh | 82a726ec60 | |
Steven Xu | 2fabf06e94 | |
Adi Thakral | 7b07f8a9dc | |
Steven Xu | ba210c7c4e | |
Steven Xu | 413a00259c | |
Yueran Zhang | c17dff1792 | |
Neil Parikh | 66dda06a1d | |
Adi Thakral | ffc1ed9d21 | |
Linhui Luo | ee4f7f5f10 | |
Amy Wang | 006f12c618 | |
Catherine Wan | 7389e555fe | |
Yueran Zhang | 9efdb8fdbf | |
Catherine Wan | b00093bd2f | |
Jared He | fe4e61dd0c | |
Bonnie Peng | ab544fc759 | |
Adi Thakral | 08fc9eb3ab | |
William Tran | 3025ffce5c | |
Aditya Thakral | 6a3c29da66 | |
Adi Thakral | 4e8efcf56f | |
Adi Thakral | d12bd73eb2 | |
William Tran | a7b4e539df |
|
@ -1 +1,10 @@
|
|||
venv
|
||||
venv
|
||||
links.db
|
||||
password.txt
|
||||
/.vs
|
||||
/.vscode
|
||||
data.json
|
||||
.env
|
||||
build/
|
||||
index.out.css
|
||||
frontend/public/index.html
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-python.python",
|
||||
"humao.rest-client"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"typescript.tsdk": "frontend/node_modules/typescript/lib",
|
||||
"eslint.format.enable": true,
|
||||
"eslint.codeActionsOnSave.mode": "all",
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
### Steps to Deploy
|
||||
- move contents of frontend/ to /srv/www-csc-links/
|
||||
- create a `.env` file in backend/ with following contents:
|
||||
```
|
||||
PASSWORD=...
|
||||
PORT=...
|
||||
```
|
||||
- run the following in backend/:
|
||||
```
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
- run python app in backend/ (with the virtual env activated)
|
||||
- edit the `.htaccess` file in /srv/www-csc-links/ to point to the running python application
|
|
@ -0,0 +1,23 @@
|
|||
## Architecture
|
||||
|
||||
![client-server interaction graph](./assets/client-server-interaction.svg)
|
||||
|
||||
## Dependencies
|
||||
|
||||
1. Node.js
|
||||
1. npm
|
||||
1. Python 3.6+
|
||||
|
||||
## Setup
|
||||
|
||||
For setting up the frontend, `setup.sh` will run `npm install` in the `frontend` folder.
|
||||
|
||||
For setting up the backend, `setup.sh` will initialize a new virtual environment and setup a dummy sqlite file for testing purposes.
|
||||
|
||||
## Dev
|
||||
|
||||
Run `setup.sh` and then run `dev.sh`
|
||||
|
||||
## Production
|
||||
|
||||
TODO
|
|
@ -0,0 +1 @@
|
|||
<mxfile host="app.diagrams.net" modified="2021-03-15T06:55:32.703Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36" etag="CQUO9gWHRxDxe8Ce6UdG" version="14.4.8" type="device"><diagram id="nWxgDZutG5X04Ajf33Mw" name="Page-1">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</diagram></mxfile>
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 26 KiB |
125
backend/main.py
125
backend/main.py
|
@ -1,9 +1,122 @@
|
|||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
from flask import Flask, request, jsonify, render_template
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@app.route('/')
|
||||
def hello_world():
|
||||
return 'Hello, World!'
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'links.db')
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth('CustomBasic')
|
||||
|
||||
password = os.environ.get("PASSWORD")
|
||||
if not password:
|
||||
raise Exception("PASSWORD must be set")
|
||||
|
||||
port = int(os.environ.get("PORT") or 3000)
|
||||
|
||||
out_path = os.environ.get("OUT_PATH") or "/srv/www-csc-links/index.html"
|
||||
|
||||
users = {
|
||||
"admin": generate_password_hash(password),
|
||||
}
|
||||
|
||||
def get_data_from_query(query):
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.row_factory = sqlite3.Row
|
||||
cur = con.cursor()
|
||||
cur.execute(query)
|
||||
|
||||
links_list = []
|
||||
for row in cur.fetchall():
|
||||
d = dict(zip(row.keys(), row))
|
||||
links_list.append(d)
|
||||
|
||||
con.close()
|
||||
return links_list
|
||||
|
||||
def regen_html(path):
|
||||
"""Gets links from DB and outputs them in HTML"""
|
||||
outfile = open(path, 'w')
|
||||
links_list = get_data_from_query('SELECT url, name FROM links WHERE active=1 ORDER BY position')
|
||||
html = render_template('template.html', links_list=links_list)
|
||||
print(html, file=outfile)
|
||||
outfile.close()
|
||||
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
if username in users and \
|
||||
check_password_hash(users.get(username), password):
|
||||
return username
|
||||
|
||||
@app.route('/links', methods = ['GET'])
|
||||
def get_links():
|
||||
links_list = get_data_from_query('SELECT url, name FROM links WHERE active=1 ORDER BY position')
|
||||
return jsonify(links_list)
|
||||
|
||||
@app.route('/editor/links', methods = ['POST'])
|
||||
@auth.login_required
|
||||
def update_links():
|
||||
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
cur = con.cursor()
|
||||
try:
|
||||
data = request.json['links']
|
||||
|
||||
items = 'url', 'name', 'clicks', 'active'
|
||||
for i in range(len(data)):
|
||||
if not(all(e in data[i] for e in items)):
|
||||
return "Bad request, some items missing from link object", 400
|
||||
|
||||
links = []
|
||||
cur.execute("begin")
|
||||
cur.execute('DELETE FROM links')
|
||||
for i in range(len(data)):
|
||||
url = data[i]['url']
|
||||
name = data[i]['name']
|
||||
clicks = data[i]['clicks']
|
||||
active = data[i]['active']
|
||||
position = i
|
||||
|
||||
newlink = (url, name, clicks, position, active)
|
||||
links.append(newlink)
|
||||
|
||||
cur.executemany('INSERT INTO links VALUES (?,?,?,?,?)', links)
|
||||
con.commit()
|
||||
con.close()
|
||||
regen_html(out_path)
|
||||
except Exception as e:
|
||||
cur.execute("rollback")
|
||||
con.close()
|
||||
raise e
|
||||
|
||||
links_list = get_data_from_query('SELECT name, url, clicks, active FROM links ORDER BY position')
|
||||
return jsonify(links_list)
|
||||
|
||||
@app.route('/editor/links', methods = ['GET'])
|
||||
@auth.login_required
|
||||
def get_editor_links():
|
||||
"""endpoint lists all URLs and clicks, returns json object for editor."""
|
||||
links_list = get_data_from_query('SELECT name, url, clicks, active FROM links ORDER BY position')
|
||||
return jsonify(links_list)
|
||||
|
||||
@app.route('/clicks', methods=['POST'])
|
||||
def update_clicks():
|
||||
if ('url' not in request.json or 'name' not in request.json):
|
||||
return 'url and/or name not found', 500
|
||||
else:
|
||||
url_id = request.json['url']
|
||||
url_name = request.json['name']
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
cur = con.cursor()
|
||||
cur.execute("UPDATE links SET clicks=clicks + 1 WHERE url=? AND name=?", [url_id, url_name])
|
||||
con.commit()
|
||||
con.close()
|
||||
return 'ok'
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
app.run(port=port, host="0.0.0.0")
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,36 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'links.db')
|
||||
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
|
||||
# array of links to store
|
||||
links = [
|
||||
('http://csclub.uwaterloo.ca/','CS Club Website',0,0,1),
|
||||
('https://www.instagram.com/uwcsclub/','Instagram',0,1,1),
|
||||
('https://www.facebook.com/uw.computerscienceclub','Facebook',0,2,1),
|
||||
('http://twitch.tv/uwcsclub','Twitch',0,3,1),
|
||||
('http://bit.ly/uwcsclub-yt','YouTube',0,4,1),
|
||||
]
|
||||
|
||||
# SQLite setup
|
||||
cur = con.cursor()
|
||||
|
||||
# test if table already exists
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='links'")
|
||||
if cur.fetchone():
|
||||
raise Exception('Links table already exists.')
|
||||
else:
|
||||
cur.execute('''CREATE TABLE links (
|
||||
url text NOT NULL,
|
||||
name text NOT NULL,
|
||||
clicks int NOT NULL,
|
||||
position int NOT NULL UNIQUE,
|
||||
active int NOT NULL,
|
||||
UNIQUE(url, name)
|
||||
)''')
|
||||
cur.executemany('INSERT INTO links VALUES (?,?,?,?,?)', links)
|
||||
con.commit()
|
||||
|
||||
con.close()
|
|
@ -0,0 +1,48 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<meta charSet="utf-8"/>
|
||||
<title>@uwcsclub | LinkList</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Karla:wght@700&display=swap" rel="stylesheet">
|
||||
<link href="index.out.css" rel="stylesheet">
|
||||
<script>
|
||||
const handleClick = function(name, url) {
|
||||
fetch("api/clicks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name, url }),
|
||||
})
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="__next">
|
||||
<div class="text-s flex flex-col items-center w-full top-6 font-karla">
|
||||
<img class="mb-3" src="images/csc-logo.png" alt="CSC Logo" width="100px" />
|
||||
<h1 class="font-bold">@uwcsclub</h1>
|
||||
<ul class="flex flex-col my-6 w-full">
|
||||
{% for link in links_list %}
|
||||
<li class="w-full flex justify-center">
|
||||
<a
|
||||
class="btn bg-gray-450 p-3 text-white font-karla font-bold text-center self-center my-1.5
|
||||
hover:bg-white hover:text-black border-2 border-gray-800 transition duration-300 ease-in-out
|
||||
w-11/12 sm:w-4/12 min-h-link"
|
||||
href="{{ link['url'] }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onclick="return handleClick("{{ link['name'] }}", "{{ link['url'] }}")"
|
||||
>
|
||||
{{ link["name"] }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
rm -rf build
|
||||
mkdir build
|
||||
|
||||
# Build Frontend
|
||||
cd frontend/public
|
||||
NODE_ENV=production npx tailwindcss-cli@latest build index.in.css -o index.out.css
|
||||
cd ..
|
||||
npm run build && npm run export
|
||||
cd ..
|
||||
mv frontend/out build/frontend
|
||||
|
||||
# Backend
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
if [ ! -f links.db ]; then
|
||||
python3 setup_db.py
|
||||
fi
|
||||
cd ..
|
||||
rsync -ax --exclude venv backend/ build/backend
|
||||
|
||||
cp README-deploy.md build
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
function prefix_stdout_stderr() {
|
||||
exec > >(trap "" INT TERM; sed "s/^/`printf "$1"`/")
|
||||
exec 2> >(trap "" INT TERM; sed "s/^/`printf "$1"`/" >&2)
|
||||
}
|
||||
|
||||
function run_frontend_backend() {
|
||||
$1 &
|
||||
pid_frontend=$!
|
||||
|
||||
$2 &
|
||||
pid_backend=$!
|
||||
|
||||
trap_ctrlc() {
|
||||
echo ""
|
||||
kill $pid_frontend
|
||||
echo "frontend exit code: $?"
|
||||
kill $pid_backend
|
||||
echo "backend exit code: $?"
|
||||
}
|
||||
|
||||
trap trap_ctrlc INT
|
||||
|
||||
wait
|
||||
}
|
33
dev.sh
33
dev.sh
|
@ -1,15 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
set -e
|
||||
|
||||
function prefix_stdout_stderr() {
|
||||
exec > >(trap "" INT TERM; sed "s/^/`printf "$1"`/")
|
||||
exec 2> >(trap "" INT TERM; sed "s/^/`printf "$1"`/" >&2)
|
||||
}
|
||||
source ./common.sh
|
||||
|
||||
function start_frontend() {
|
||||
function dev_frontend() {
|
||||
prefix_stdout_stderr "${PURPLE}frontend: ${NC}"
|
||||
|
||||
cd ./frontend
|
||||
|
@ -17,31 +12,13 @@ function start_frontend() {
|
|||
npm run dev
|
||||
}
|
||||
|
||||
function start_backend() {
|
||||
function dev_backend() {
|
||||
prefix_stdout_stderr "${CYAN}backend: ${NC}"
|
||||
|
||||
cd ./backend
|
||||
source venv/bin/activate
|
||||
|
||||
echo $(which python)
|
||||
|
||||
python main.py
|
||||
}
|
||||
|
||||
start_frontend &
|
||||
pid_frontend=$!
|
||||
|
||||
start_backend &
|
||||
pid_backend=$!
|
||||
|
||||
trap_ctrlc() {
|
||||
echo ""
|
||||
kill $pid_frontend
|
||||
echo "frontend exit code: $?"
|
||||
kill $pid_backend
|
||||
echo "backend exit code: $?"
|
||||
}
|
||||
|
||||
trap trap_ctrlc INT
|
||||
|
||||
wait
|
||||
run_frontend_backend "dev_frontend" "dev_backend"
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
|
||||
interface AnalyticsProps {
|
||||
clicks: number;
|
||||
}
|
||||
|
||||
const Analytics: React.FC<AnalyticsProps> = ({ clicks }) => {
|
||||
return (
|
||||
<div className="w-full h-12 lt-lg:h-16 mr-0 px-4 lt-lg:px-6 flex border-b border-gray-300 text-sm font-karla">
|
||||
<div className="w-full h-full flex-analytics flex items-center">
|
||||
<span className="mr-4 font-bold">Lifetime Analytics:</span>
|
||||
<div className="mr-8 flex justify-center items-center">
|
||||
<div className="h-2 w-2 mr-2 rounded bg-analytics-click-icon"></div>
|
||||
<div className="flex text-xs lt-lg:text-base font-normal">
|
||||
<p className="whitespace-pre">Clicks: </p>
|
||||
<p className="font-bold lt-lg:font-normal">{clicks || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
|
@ -1,7 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
const EditLink: React.FC = () => {
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export default EditLink;
|
|
@ -0,0 +1,4 @@
|
|||
.input {
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import React from "react";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import styles from "./Link.module.css";
|
||||
|
||||
export type EditableLink = {
|
||||
name: string;
|
||||
url: string;
|
||||
active: boolean;
|
||||
clicks: number;
|
||||
};
|
||||
|
||||
interface LinkProps {
|
||||
index: number;
|
||||
link: EditableLink;
|
||||
onChange: (newLink: EditableLink) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const DeleteIcon = () => (
|
||||
<svg
|
||||
color="palette.blueGrey7"
|
||||
viewBox="0 0 16 16"
|
||||
enableBackground="new 0 0 24 24"
|
||||
className="h-4 inline"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 2.5v-2h4v2M1 2.5h14M9.533 13.5l.25-9M6.217 4.5l.25 9M2.661 4.5l.889 11h8.9l.888-11"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DragIcon = () => (
|
||||
<svg width="4" height="16" viewBox="0 0 4 16">
|
||||
<path d="M0 2C0 .897.897 0 2 0s2 .897 2 2-.897 2-2 2-2-.897-2-2M0 8c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2-2-.897-2-2M0 14c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2-2-.897-2-2"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
|
||||
return (
|
||||
<Draggable key={index} draggableId={index.toString()} index={index}>
|
||||
{(provided) => (
|
||||
<div {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<div className="py-2 flex rounded-md md:container">
|
||||
<div
|
||||
className="flex justify-center w-1/12 flex items-center border-r bg-white rounded-sm shadow-sm"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<DragIcon />
|
||||
</div>
|
||||
<div className="w-11/12 space-y-2 bg-white rounded-sm shadow-sm">
|
||||
<div className="flex justify-between items-center px-2 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Edit Title"
|
||||
value={link.name}
|
||||
className={styles.input}
|
||||
onChange={(e) => onChange({ ...link, name: e.target.value })}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) =>
|
||||
onChange({ ...link, active: e.target.checked })
|
||||
}
|
||||
defaultChecked={link.active}
|
||||
className="float-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center px-2 pb-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://url"
|
||||
value={link.url}
|
||||
className={styles.input}
|
||||
onChange={(e) => onChange({ ...link, url: e.target.value })}
|
||||
/>
|
||||
|
||||
<button onClick={onDelete}>
|
||||
<DeleteIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-2 pb-2">
|
||||
{`Clicks: ${link.clicks}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
|
@ -0,0 +1,143 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { DragDropContext, Droppable, DropResult } from "react-beautiful-dnd";
|
||||
import Link, { EditableLink } from "components/Editor/Link";
|
||||
import { useAuth } from "components/Login/AuthContext";
|
||||
import Preview from "components/Preview";
|
||||
|
||||
import { useDragDrop } from "./useDragDrop";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
interface EditorProps {
|
||||
links: EditableLink[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EditableLink[]>>;
|
||||
}
|
||||
|
||||
const Editor: React.FC<EditorProps> = ({ links, setLinks }) => {
|
||||
const { displayDragDrop } = useDragDrop();
|
||||
const { headers } = useAuth();
|
||||
const [editableLinks, setEditableLinks] = useState<EditableLink[]>(links);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableLinks(links);
|
||||
}, [links]);
|
||||
|
||||
const handleOnDragEnd = (result: DropResult) => {
|
||||
if (!result?.destination) return;
|
||||
|
||||
const items = Array.from(editableLinks);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setEditableLinks(items);
|
||||
};
|
||||
|
||||
/*note that we need to make the new links name render with nothing*/
|
||||
const handleOnClickAdd = () =>
|
||||
setEditableLinks([
|
||||
...editableLinks,
|
||||
{
|
||||
name: "",
|
||||
url: "",
|
||||
clicks: 0,
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
const res = await fetch("api/editor/links", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ links: editableLinks }),
|
||||
});
|
||||
|
||||
const updatedLinks = await res.json();
|
||||
setLinks(updatedLinks);
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
setEditableLinks(links);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-gray-100 md:flex-row">
|
||||
<div className="space-y-4 md:w-3/5 md:border-r md:border-gray-300">
|
||||
<div className="m-8">
|
||||
<button
|
||||
className="block flex py-2 items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-500 cursor-pointer text-white w-full"
|
||||
onClick={handleOnClickAdd}
|
||||
>
|
||||
Add New Link
|
||||
</button>
|
||||
<div className="mb-8" />
|
||||
{displayDragDrop && (
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Droppable droppableId="links">
|
||||
{(provided) => (
|
||||
<div
|
||||
className="links"
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{editableLinks.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
index={index}
|
||||
link={link}
|
||||
onChange={(newLink: EditableLink) =>
|
||||
setEditableLinks([
|
||||
...editableLinks.slice(0, index),
|
||||
newLink,
|
||||
...editableLinks.slice(index + 1),
|
||||
])
|
||||
}
|
||||
onDelete={() =>
|
||||
setEditableLinks([
|
||||
...editableLinks.slice(0, index),
|
||||
...editableLinks.slice(index + 1),
|
||||
])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
<div className="mb-8" />
|
||||
<div className="flex">
|
||||
<button
|
||||
className="block flex py-2 items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-500 cursor-pointer text-white w-full disabled:opacity-50"
|
||||
onClick={onSubmit}
|
||||
disabled={isSaving || equal(editableLinks, links)}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<div className="mr-4" />
|
||||
<button
|
||||
className="block flex py-2 items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-500 cursor-pointer text-white w-full disabled:opacity-50"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving || equal(editableLinks, links)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 md:none" />
|
||||
<div className="flex m-14 justify-center md:w-2/5">
|
||||
<Preview links={editableLinks.filter((link) => link.active)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
|
@ -0,0 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
type TUseDragDrop = () => { displayDragDrop: boolean };
|
||||
|
||||
export const useDragDrop: TUseDragDrop = () => {
|
||||
const [isBrowser, setIsBrowser] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsBrowser(process.browser);
|
||||
}, []);
|
||||
|
||||
return { displayDragDrop: isBrowser };
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
const Link: React.FC = () => {
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export default Link;
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
|
||||
export interface Link {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
interface Props {
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export const Links: React.FC<Props> = ({ links }) => {
|
||||
return (
|
||||
<div className="text-s flex flex-col items-center w-full top-6 font-karla">
|
||||
<img className="mb-3" src="images/csc-logo.png" alt="CSC Logo" width="100px" />
|
||||
<h1 className="font-bold">@uwcsclub</h1>
|
||||
<ul className="flex flex-col my-6 w-full">
|
||||
{links.map(({ name, url }, i) => (
|
||||
<li key={i} className="w-full flex justify-center">
|
||||
<a
|
||||
className="btn bg-gray-450 p-3 text-white font-karla font-bold text-center self-center my-1.5
|
||||
hover:bg-white hover:text-black border-2 border-gray-800 transition duration-300 ease-in-out
|
||||
w-11/12 sm:w-4/12 min-h-link"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
import React, { useState, useContext, createContext } from "react";
|
||||
|
||||
interface AuthState {
|
||||
loggedIn: boolean;
|
||||
login: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
loggedIn: false,
|
||||
login: () => Promise.resolve(false),
|
||||
logout: () => console.error("No parent AuthContext found!"),
|
||||
});
|
||||
|
||||
export const AuthProvider: React.FC = ({ children }) => {
|
||||
const [headers, setHeaders] = useState<HeadersInit | undefined>();
|
||||
|
||||
const logout = () => setHeaders(undefined);
|
||||
|
||||
const login = async (password: string): Promise<boolean> => {
|
||||
const username = "admin";
|
||||
|
||||
const newHeaders = {
|
||||
Authorization: `CustomBasic ${btoa(`${username}:${password}`)}`,
|
||||
};
|
||||
|
||||
const res = await fetch("api/editor/links", { headers: newHeaders });
|
||||
|
||||
if (res.status === 200) {
|
||||
setHeaders(newHeaders);
|
||||
return true;
|
||||
} else {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ loggedIn: !!headers, login, logout, headers }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useState } from "react";
|
||||
import { useAuth } from "components/Login/AuthContext";
|
||||
import Head from "next/head";
|
||||
|
||||
const LoginBox: React.FC = () => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [loginFailed, setLoginFailed] = useState(false);
|
||||
const { loggedIn, login } = useAuth();
|
||||
|
||||
const passwordLabelClassName = `absolute inset-y-0 left-0 px-4 font-sans text-gray-600 ${
|
||||
focused || password
|
||||
? "transform scale-75 -translate-y-5 -translate-x-2"
|
||||
: ""
|
||||
} transition-transform pointer-events-none`;
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!loggedIn) {
|
||||
const loginSuccessful = await login(password);
|
||||
|
||||
if (!loginSuccessful) {
|
||||
setLoginFailed(true);
|
||||
setPassword("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed h-screen w-full overflow-auto bg-gray-100">
|
||||
<div className="m-auto h-full flex justify-center items-center">
|
||||
<div className="container m-auto h-auto flex flex-col justify-center items-center p-10 space-y-20">
|
||||
<Head>
|
||||
<title>Login</title>
|
||||
</Head>
|
||||
<div className="flex flex-col justify-center items-center space-y-10">
|
||||
<div className="flex flex-row justify-center items-center space-x-5">
|
||||
<img
|
||||
src="images/csc-logo-trans.png"
|
||||
height={80}
|
||||
width={80}
|
||||
alt="CSC Logo"
|
||||
/>
|
||||
<h1 className="text-4xl font-sans font-bold text-gray-900">
|
||||
linklist
|
||||
</h1>
|
||||
</div>
|
||||
<h2 className="text-xl font-sans font-semibold text-gray-900 text-center">
|
||||
Log in to continue to your Linklist admin
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex justify-center items-center px-10 py-8 bg-gray-50 border-2 border-gray-300 rounded-lg">
|
||||
<div className="space-y-4">
|
||||
{loginFailed ? (
|
||||
<div className="text-red-600">Invalid credentials.</div>
|
||||
) : null}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="password" className="relative">
|
||||
<span className={passwordLabelClassName}>Password</span>
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
setLoginFailed(false);
|
||||
}}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="bg-transparent p-4 border border-gray-300 leading-snug focus:outline-none focus:border-gray-500 rounded"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Log In"
|
||||
className="w-full px-4 py-2 font-sans font-semibold text-white bg-purple-700 focus:outline-none focus:ring-4 focus:ring-purple-300 rounded-lg"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginBox;
|
|
@ -1,7 +1,23 @@
|
|||
import { Link, Links } from "components/Links";
|
||||
import React from "react";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const Preview: React.FC = () => {
|
||||
return <div />;
|
||||
interface PreviewProps {
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const Preview: React.FC<PreviewProps> = ({ links }) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={`box-border bg-white rounded-preview border-preview border-black ${styles.parent}`}
|
||||
>
|
||||
<div className={styles.child}>
|
||||
<Links links={links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preview;
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
.parent {
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-right: 17px; /* Increase/decrease this value for cross-browser compatibility */
|
||||
padding-left: 7px;
|
||||
box-sizing: content-box; /* So the width will be 100% + 17px */
|
||||
}
|
||||
.child a {
|
||||
width: 90%;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export { default as Link } from "./Link";
|
||||
export { default as EditLink } from "./EditLink";
|
||||
export { default as Preview } from "./Preview";
|
|
@ -0,0 +1,29 @@
|
|||
// @ts-check
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const { PHASE_DEVELOPMENT_SERVER } = require("next/constants");
|
||||
|
||||
const devConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api",
|
||||
destination: "http://localhost:5000",
|
||||
},
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://localhost:5000/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const prodConfig = {
|
||||
basePath: "/links",
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
module.exports = (phase) =>
|
||||
phase === PHASE_DEVELOPMENT_SERVER ? devConfig : prodConfig;
|
File diff suppressed because it is too large
Load Diff
|
@ -5,23 +5,30 @@
|
|||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"export": "next export",
|
||||
"type-check": "tsc",
|
||||
"format": "prettier --write './**/*'",
|
||||
"format:check": "prettier --check './**/*'",
|
||||
"lint": "eslint \"{pages,components}/**/*.{js,ts,tsx,jsx}\" --quiet --fix",
|
||||
"lint:check": "eslint \"{pages,components}/**/*.{js,ts,tsx,jsx}\" --quiet",
|
||||
"check": "npm run format:check && npm run lint:check",
|
||||
"check:fix": "npm run format && npm run lint"
|
||||
"check:fix": "npm run format && npm run lint",
|
||||
"clean-cache": "rm -rf ./.next"
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"next": "10.0.7",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1"
|
||||
"react-beautiful-dnd": "^13.0.0",
|
||||
"react-dom": "17.0.1",
|
||||
"styled-components": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "^17.0.1",
|
||||
"@types/styled-components": "^5.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^4.16.1",
|
||||
"@typescript-eslint/parser": "^4.16.1",
|
||||
"autoprefixer": "^10.2.4",
|
||||
|
@ -32,7 +39,7 @@
|
|||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"husky": "^5.1.3",
|
||||
"postcss": "^8.2.6",
|
||||
"postcss": "^8.2.7",
|
||||
"prettier": "^2.2.1",
|
||||
"stylelint": "^13.11.0",
|
||||
"stylelint-config-standard": "^20.0.0",
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
import type { AppProps } from "next/app";
|
||||
import React from "react";
|
||||
import "styles/globals.css";
|
||||
import Head from "next/head";
|
||||
|
||||
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => (
|
||||
<Component {...pageProps} />
|
||||
<>
|
||||
<Head>
|
||||
<title>@uwcsclub | LinkList</title>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default MyApp;
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { AuthProvider, useAuth } from "components/Login/AuthContext";
|
||||
import Login from "components/Login";
|
||||
import Analytics from "components/Analytics";
|
||||
import Editor from "components/Editor";
|
||||
import { EditableLink } from "components/Editor/Link";
|
||||
|
||||
const EditorPage: React.FC = () => {
|
||||
const { loggedIn, headers } = useAuth();
|
||||
const [links, setLinks] = useState<EditableLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLinks() {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const res = await fetch("api/editor/links", { headers });
|
||||
const links = await res.json();
|
||||
setLinks(links);
|
||||
}
|
||||
|
||||
fetchLinks();
|
||||
}, [loggedIn, headers]);
|
||||
|
||||
return loggedIn ? (
|
||||
<div>
|
||||
<Analytics clicks={links.reduce((acc, curr) => acc + curr.clicks, 0)} />
|
||||
<Editor links={links} setLinks={setLinks} />
|
||||
</div>
|
||||
) : (
|
||||
<Login />
|
||||
);
|
||||
};
|
||||
|
||||
export default function EditorPageWrapper(): JSX.Element {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<EditorPage />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
import Head from "next/head";
|
||||
import React from "react";
|
||||
import { GetStaticProps } from "next";
|
||||
import styles from "styles/Home.module.css";
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
// TODO: Fetch links here
|
||||
|
||||
return {
|
||||
props: { data: null }, // will be passed to the page component as props
|
||||
// Next.js will attempt to re-generate the page:
|
||||
// - When a request comes in
|
||||
// - At most once every second
|
||||
revalidate: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const Editor: React.FC = ({ data }: any) => {
|
||||
console.log({ data });
|
||||
|
||||
return (
|
||||
// TODO: Remove starter code
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className="max-w-md mx-auto text-red-600 hover:text-red-700">
|
||||
<div>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{" "}
|
||||
<code className={styles.code}>pages/index.js</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
||||
<h3>Documentation →</h3>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
||||
<h3>Learn →</h3>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Examples →</h3>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Deploy →</h3>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{" "}
|
||||
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
|
@ -1,73 +0,0 @@
|
|||
import Head from "next/head";
|
||||
import React from "react";
|
||||
import styles from "styles/Home.module.css";
|
||||
|
||||
const Login: React.FC = () => {
|
||||
return (
|
||||
// TODO: Remove starter code
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className="max-w-md mx-auto text-red-600 hover:text-red-700">
|
||||
<div>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{" "}
|
||||
<code className={styles.code}>pages/index.js</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
||||
<h3>Documentation →</h3>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
||||
<h3>Learn →</h3>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Examples →</h3>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Deploy →</h3>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{" "}
|
||||
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
|
@ -1,84 +0,0 @@
|
|||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import { GetStaticProps } from "next";
|
||||
import styles from "styles/Home.module.css";
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
// TODO: Fetch links here
|
||||
|
||||
return {
|
||||
props: { links: [] }, // will be passed to the page component as props
|
||||
// Next.js will attempt to re-generate the page:
|
||||
// - When a request comes in
|
||||
// - At most once every second
|
||||
revalidate: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const Home: React.FC = ({ links }: any) => {
|
||||
console.log({ links });
|
||||
|
||||
// TODO: Remove starter code
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{" "}
|
||||
<code className={styles.code}>pages/index.js</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
||||
<h3>Documentation →</h3>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
||||
<h3>Learn →</h3>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Examples →</h3>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
>
|
||||
<h3>Deploy →</h3>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{" "}
|
||||
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
|
@ -0,0 +1,5 @@
|
|||
RewriteEngine On
|
||||
|
||||
RewriteCond %{SCRIPT_FILENAME} !-d
|
||||
RewriteCond %{SCRIPT_FILENAME} !-f
|
||||
RewriteRule "^api/(.*)$" "http://corn-syrup:5730/$1" [P]
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
After Width: | Height: | Size: 189 KiB |
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
|
@ -0,0 +1,17 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* NextJS reset styles https://gist.github.com/dmurawsky/d45f068097d181c733a53687edce1919 */
|
||||
html,
|
||||
body,
|
||||
body > div:first-child,
|
||||
div#__next,
|
||||
div#__next > div {
|
||||
height: 100%;
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
module.exports = {
|
||||
purge: {
|
||||
content: [
|
||||
"../../backend/templates/template.html",
|
||||
],
|
||||
// These options are passed through directly to PurgeCSS
|
||||
options: {
|
||||
keyframes: true,
|
||||
fontFace: true,
|
||||
},
|
||||
},
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
theme: {
|
||||
fontFamily: {
|
||||
body: ["Karla", "sans-serif"],
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
analytics: {
|
||||
"view-icon": "#39e09b",
|
||||
"click-icon": "#8a86e5",
|
||||
},
|
||||
gray: {
|
||||
450: "#3d3b3c",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Karla", "Helvetica", "Ubuntu", "sans-serif"],
|
||||
karla: ["Karla", "Verdana", "sans-serif"],
|
||||
},
|
||||
fontSize: {
|
||||
s: ".82rem",
|
||||
},
|
||||
screens: {
|
||||
"lt-lg": "992px",
|
||||
},
|
||||
flex: {
|
||||
analytics: "1 0 0",
|
||||
chevron: "0 0 16px",
|
||||
},
|
||||
borderRadius: {
|
||||
preview: "3.2rem",
|
||||
},
|
||||
borderWidth: {
|
||||
preview: "0.75rem",
|
||||
},
|
||||
minHeight: {
|
||||
link: "48px",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {
|
||||
opacity: ["disabled"],
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
|
@ -1,122 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,19 @@
|
|||
/* ./your-css-folder/styles.css */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* NextJS reset styles https://gist.github.com/dmurawsky/d45f068097d181c733a53687edce1919 */
|
||||
html,
|
||||
body,
|
||||
body > div:first-child,
|
||||
div#__next,
|
||||
div#__next > div {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
module.exports = {
|
||||
purge: {
|
||||
content: [
|
||||
|
@ -12,10 +13,47 @@ module.exports = {
|
|||
},
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {},
|
||||
fontFamily: {
|
||||
body: ["Karla", "sans-serif"],
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
analytics: {
|
||||
"view-icon": "#39e09b",
|
||||
"click-icon": "#8a86e5",
|
||||
},
|
||||
gray: {
|
||||
450: "#3d3b3c",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Karla", "Helvetica", "Ubuntu", "sans-serif"],
|
||||
karla: ["Karla", "Verdana", "sans-serif"],
|
||||
},
|
||||
fontSize: {
|
||||
s: ".82rem",
|
||||
},
|
||||
screens: {
|
||||
"lt-lg": "992px",
|
||||
},
|
||||
flex: {
|
||||
analytics: "1 0 0",
|
||||
chevron: "0 0 16px",
|
||||
},
|
||||
borderRadius: {
|
||||
preview: "3.2rem",
|
||||
},
|
||||
borderWidth: {
|
||||
preview: "0.75rem",
|
||||
},
|
||||
minHeight: {
|
||||
link: "48px",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
extend: {
|
||||
opacity: ["disabled"],
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
source ./common.sh
|
||||
MAIN_PATH=$(pwd)
|
||||
|
||||
function setup_frontend() {
|
||||
prefix_stdout_stderr "${PURPLE}frontend: ${NC}"
|
||||
|
||||
cd ./frontend
|
||||
|
||||
echo "Installing dependencies..."
|
||||
npm i
|
||||
|
||||
echo "Building tailwind css file for links page"
|
||||
cd public
|
||||
NODE_ENV=production npx tailwindcss-cli@latest build index.in.css -o index.out.css
|
||||
|
||||
echo "Done!"
|
||||
}
|
||||
|
||||
function setup_backend() {
|
||||
prefix_stdout_stderr "${CYAN}backend: ${NC}"
|
||||
|
||||
cd ./backend
|
||||
|
||||
echo "Deleting old virtual environment..."
|
||||
rm -rf ./venv
|
||||
|
||||
echo "Deleting sqlite database..."
|
||||
rm ./links.db || echo "Nothing to delete ¯\_(ツ)_/¯"
|
||||
|
||||
echo "Deleting .env file"
|
||||
rm ./.env || echo "No .env to delete"
|
||||
|
||||
echo "Creating new virtual environment..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo "Creating a dummy sqlite database at 'backend/links.db'..."
|
||||
python setup_db.py
|
||||
|
||||
echo "Creating .env file"
|
||||
echo "PASSWORD=test" > .env
|
||||
echo "PORT=5000" >> .env
|
||||
echo "OUT_PATH=$MAIN_PATH/frontend/public/index.html" >> .env
|
||||
|
||||
echo "Done!"
|
||||
}
|
||||
|
||||
run_frontend_backend "setup_frontend" "setup_backend"
|
Loading…
Reference in New Issue