Compare commits

..

7 Commits

Author SHA1 Message Date
Aditya Thakral 50914f289d Hookup backend auth to the editor 2021-04-02 02:22:16 -04:00
Aditya Thakral 68f012ba62 Update formState when links change 2021-04-02 02:20:15 -04:00
Aditya Thakral 168b3879af Allow even nested routes under /api 2021-04-02 02:18:27 -04:00
Aditya Thakral c7e40722d1 Hook up / with the links.json file 2021-04-01 19:09:02 -04:00
Aditya Thakral e5bc1a5802 Dont export default from components 2021-04-01 19:08:14 -04:00
Aditya Thakral 4c4a7d7009 Add type-check back 2021-04-01 19:07:50 -04:00
Aditya Thakral 7e9ff1dfb9 Rename title to name 2021-04-01 19:03:30 -04:00
43 changed files with 888 additions and 1029 deletions

6
.gitignore vendored
View File

@ -3,8 +3,4 @@ links.db
password.txt
/.vs
/.vscode
data.json
.env
build/
index.out.css
frontend/public/index.html
/links.json

View File

@ -1,15 +0,0 @@
### 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

View File

@ -1,51 +1,33 @@
from flask import Flask, request, jsonify, render_template
from flask import Flask, request, jsonify
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
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),
"admin": generate_password_hash("test"),
}
def get_data_from_query(query):
def regen_JSON():
"""Gets links from DB and outputs them in JSON"""
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute(query)
cur.execute('SELECT url, name FROM links ORDER BY position')
links_list = []
for row in cur.fetchall():
d = dict(zip(row.keys(), row))
links_list.append(d)
d = dict(zip(row.keys(), row))
links_list.append(d)
links_json = json.dumps(links_list, indent=4)
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()
return links_json
@auth.verify_password
def verify_password(username, password):
@ -53,55 +35,56 @@ def verify_password(username, password):
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'])
@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')
links = []
data = request.json['links']
items = 'url', 'name', 'clicks', 'position'
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
url = data[i]['url']
name = data[i]['name']
clicks = data[i]['clicks']
active = data[i]['active']
position = i
position = data[i]['position'] # TODO
newlink = (url, name, clicks, position, active)
newlink = (url, name, clicks, position)
links.append(newlink)
cur.executemany('INSERT INTO links VALUES (?,?,?,?,?)', links)
cur.executemany('INSERT INTO links VALUES (?,?,?,?)', links)
con.commit()
con.close()
regen_html(out_path)
except Exception as e:
data = regen_JSON()
# TODO: Trigger a rebuild of the frontend
outfile = open('data.json', 'w')
print(data, file=outfile)
outfile.close()
except:
cur.execute("rollback")
con.close()
raise e
return "done updating links"
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'])
@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')
def get_links():
# endpoint lists all URLs and clicks, returns json object for editor.
con = sqlite3.connect(DB_PATH)
cur = con.cursor()
cur.execute('SELECT position, name, url, clicks FROM links ORDER BY position')
links_list = []
for row in cur.fetchall():
d = dict(zip(["position", "name", "url", "clicks"], [row[0], row[1], row[2], row[3]]))
links_list.append(d)
con.close()
return jsonify(links_list)
@app.route('/clicks', methods=['POST'])
@ -119,4 +102,4 @@ def update_clicks():
return 'ok'
if __name__ == "__main__":
app.run(port=port, host="0.0.0.0")
app.run(debug=True)

View File

@ -1,7 +1,6 @@
astroid==2.5.1
click==7.1.2
Flask==1.1.2
Flask-HTTPAuth==4.2.0
isort==5.7.0
itsdangerous==1.1.0
Jinja2==2.11.3
@ -9,9 +8,7 @@ lazy-object-proxy==1.5.2
MarkupSafe==1.1.1
mccabe==0.6.1
pylint==2.7.2
python-dotenv==0.17.1
toml==0.10.2
typed-ast==1.4.3
types-click==0.1.4
types-Flask==0.1.1
types-Jinja2==0.1.0
@ -20,3 +17,4 @@ types-typing-extensions==3.7.2
types-Werkzeug==0.1.1
Werkzeug==1.0.1
wrapt==1.12.1
Flask-HTTPAuth==4.2.0

View File

@ -7,11 +7,11 @@ 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),
('http://csclub.uwaterloo.ca/','CS Club Website',3,0),
('https://www.instagram.com/uwcsclub/','Instagram',4,1),
('https://www.facebook.com/uw.computerscienceclub','Facebook',5,2),
('http://twitch.tv/uwcsclub','Twitch',6,3),
('http://bit.ly/uwcsclub-yt','YouTube',7,4),
]
# SQLite setup
@ -23,14 +23,13 @@ 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,
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)
cur.executemany('INSERT INTO links VALUES (?,?,?,?)', links)
con.commit()
con.close()
con.close()

View File

@ -1,48 +0,0 @@
<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(&quot;{{ link['name'] }}&quot;, &quot;{{ link['url'] }}&quot;)"
>
{{ link["name"] }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</body>
</html>

View File

@ -1,22 +0,0 @@
#!/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

View File

@ -0,0 +1,43 @@
import React from "react";
const Question = (
<svg
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
color="#000"
viewBox="0 0 16 16"
className="sc-dkIXFM jmmNux"
width="14"
height="14"
>
<path d="M8 0a8 8 0 108 8 8 8 0 00-8-8zm0 15a7 7 0 117-7 7 7 0 01-7 7zm-.75-3.49h1.53V13H7.25zm3.54-7A3.05 3.05 0 0111 5.72a2.54 2.54 0 01-.1.76 2.9 2.9 0 01-.27.62 3.75 3.75 0 01-.38.51l-.44.45-.43.42a5.58 5.58 0 00-.38.46 2.33 2.33 0 00-.27.52 1.83 1.83 0 00-.11.64v.54H7.44V10a2.57 2.57 0 01.22-1 3 3 0 01.45-.73 6.25 6.25 0 01.57-.58 7.4 7.4 0 00.55-.57 2.42 2.42 0 00.4-.65 1.82 1.82 0 00.13-.87 1.81 1.81 0 00-.49-1.19A1.63 1.63 0 008.09 4a1.91 1.91 0 00-.86.18 1.74 1.74 0 00-.59.49 1.91 1.91 0 00-.36.71 3.7 3.7 0 00-.11.9H5A3.71 3.71 0 015.2 5a3.17 3.17 0 01.61-1.1 2.85 2.85 0 011-.69A3.21 3.21 0 018.09 3a3.4 3.4 0 011.18.19 2.71 2.71 0 01.92.53 2.6 2.6 0 01.6.83z"></path>
</svg>
);
const Chevron = (
<svg
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
className="sc-hHftDr bphBpT"
rotate="0"
data-test="IconComponent"
width="16"
height="20"
>
<rect
rotate="0"
data-test="IconComponent"
className="sc-fKFyDc gYoavR"
fillOpacity="0"
fill="#000000"
></rect>
<polygon
transform="translate(5.949747, 4.949747) rotate(-45.000000) translate(-5.949747, -4.949747) "
points="4.44974747 6.44974747 4.44974747 1.44974747 2.44974747 1.44974747 2.44974747 8.44974747 9.44974747 8.44974747 9.44974747 6.44974747"
fill="#0A0B0D"
></polygon>
</svg>
);
export { Question, Chevron };

View File

@ -1,21 +1,47 @@
import React from "react";
import { useState, useEffect } from "react";
import { Question, Chevron } from "./assets";
interface AnalyticsProps {
clicks: number;
}
const Analytics: React.FC = () => {
const [viewCount, setViewCount] = useState(0);
const [clickCount, setClickCount] = useState(0);
useEffect(() => {
fetch("https://dog.ceo/api/breeds/list/all") // TODO: Change to '/api/editor/links'
.then((results) => results.json())
.then((data) => {
console.log("Success:", data);
// TODO: Assign the correct values here:
// setViewCount(data.views);
// setClickCount(data.clicks);
})
.catch((error) => {
console.error("Error:", error);
});
}, []);
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">
<div className="w-full h-12 lt-lg:h-16 mr-0 px-4 lt-lg:px-6 flex flex-row border-b border-analytics-border text-sm font-karla">
<div className="w-full h-full flex-analytics flex flex-row justify-start 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 className="mr-8 flex flex-row justify-center items-center">
<div className="h-2 w-2 mr-2 rounded bg-analytics-view-icon"></div>
<div className="flex flex-col lt-lg:flex-row text-xs lt-lg:text-base font-normal">
<p className="whitespace-pre">Views: </p>
<p className="font-bold lt-lg:font-normal">{viewCount || "-"}</p>
</div>
</div>
<div className="mr-8 flex flex-row justify-center items-center">
<div className="h-2 w-2 mr-2 rounded bg-analytics-click-icon"></div>
<div className="flex flex-col lt-lg:flex-row text-xs lt-lg:text-base font-normal">
<p className="whitespace-pre">Clicks: </p>
<p className="font-bold lt-lg:font-normal">{clickCount || "-"}</p>
</div>
</div>
<div>{Question}</div>
</div>
<div className="w-full h-full flex-chevron flex flex-row justify-center items-center">
{Chevron}
</div>
</div>
);

View File

@ -1,4 +0,0 @@
.input {
width: 100%;
padding-right: 16px;
}

View File

@ -1,6 +1,5 @@
import React from "react";
import { Draggable } from "react-beautiful-dnd";
import styles from "./Link.module.css";
export type EditableLink = {
name: string;
@ -44,7 +43,7 @@ const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
<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="p-2 container flex">
<div
className="flex justify-center w-1/12 flex items-center border-r bg-white rounded-sm shadow-sm"
{...provided.dragHandleProps}
@ -57,7 +56,6 @@ const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
type="text"
placeholder="Edit Title"
value={link.name}
className={styles.input}
onChange={(e) => onChange({ ...link, name: e.target.value })}
/>
@ -66,7 +64,7 @@ const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
onChange={(e) =>
onChange({ ...link, active: e.target.checked })
}
defaultChecked={link.active}
checked={link.active}
className="float-right"
/>
</div>
@ -76,7 +74,6 @@ const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
type="url"
placeholder="https://url"
value={link.url}
className={styles.input}
onChange={(e) => onChange({ ...link, url: e.target.value })}
/>
@ -84,9 +81,6 @@ const Link: React.FC<LinkProps> = ({ index, link, onChange, onDelete }) => {
<DeleteIcon />
</button>
</div>
<div className="flex justify-between items-center px-2 pb-2">
{`Clicks: ${link.clicks}`}
</div>
</div>
</div>
</div>

View File

@ -1,8 +1,6 @@
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";
@ -12,30 +10,28 @@ interface EditorProps {
setLinks: React.Dispatch<React.SetStateAction<EditableLink[]>>;
}
const Editor: React.FC<EditorProps> = ({ links, setLinks }) => {
const Editor: React.FC<EditorProps> = ({ links }) => {
const [formState, setFormState] = useState<EditableLink[]>(links);
const { displayDragDrop } = useDragDrop();
const { headers } = useAuth();
const [editableLinks, setEditableLinks] = useState<EditableLink[]>(links);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setEditableLinks(links);
setFormState(links);
}, [links]);
const handleOnDragEnd = (result: DropResult) => {
if (!result?.destination) return;
const items = Array.from(editableLinks);
const items = Array.from(formState);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setEditableLinks(items);
setFormState(items);
};
/*note that we need to make the new links name render with nothing*/
const handleOnClickAdd = () =>
setEditableLinks([
...editableLinks,
setFormState([
...formState,
{
name: "",
url: "",
@ -44,98 +40,63 @@ const Editor: React.FC<EditorProps> = ({ links, setLinks }) => {
},
]);
const onSubmit = async () => {
setIsSaving(true);
// useEffect(() => {
// setFormState(links);
// }, [links]);
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 onSubmit = () => {
// const res = await updateLinks(formState);
// setLinks(res.data);
};
const onCancel = () => {
setEditableLinks(links);
};
const didEdit = !equal(formState, links);
console.log({ formState, didEdit });
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 className="space-y-4 bg-gray-100 w-1/2 p-2">
{/* add link button */}
<button
className="block flex py-2 items-center justify-center rounded-md bg-purple-600 hover:bg-purple-500 cursor-pointer text-white w-full"
onClick={handleOnClickAdd}
>
Add New Link
</button>
{displayDragDrop && (
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId="links">
{(provided) => (
<div
className="links"
{...provided.droppableProps}
ref={provided.innerRef}
>
{formState.map((link, index) => (
<Link
key={index}
index={index}
link={link}
onChange={(newLink: EditableLink) =>
setFormState([
...formState.slice(0, index),
newLink,
...formState.slice(index + 1),
])
}
onDelete={() =>
setFormState([
...formState.slice(0, index),
...formState.slice(index + 1),
])
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
);
};

View File

@ -4,25 +4,47 @@ export interface Link {
name: string;
url: string;
}
interface Props {
interface LinkProps {
links: Link[];
}
export const Links: React.FC<Props> = ({ links }) => {
export const Links: React.FC<LinkProps> = ({ links }) => {
const postData = (url = ""): void => {
fetch(url, {
method: "POST",
})
.then((response) => response.json())
.then((data) => {
console.log("Success:", data);
})
.catch((error) => {
console.error("Error:", error);
});
};
// useEffect((): void => {
// postData("https://dog.ceo/api/breeds/list/all"); // TODO: Change to '/api/view'
// }, []);
const handleClick = (): void => {
postData("https://dog.ceo/api/breeds/list/all"); // TODO: Change to '/api/click'
};
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" />
<div className="text-s flex flex-col items-center w-full absolute top-6 font-karla">
<img className="mb-3" src="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">
<li key={i} className="w-full contents">
<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"
className="btn bg-gray-450 p-3 text-white font-bold text-center self-center my-1.5
hover:bg-white hover:text-black border-2 border-gray-800 transition duration-200 ease-in-out
w-11/12 sm:w-4/12"
href={url}
target="_blank"
rel="noreferrer"
onClick={handleClick}
>
{name}
</a>

View File

@ -1,50 +0,0 @@
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);
}

View File

@ -0,0 +1,71 @@
import React, { useState, useContext, createContext } from "react";
interface LoggedInState {
loggedIn: true;
headers: HeadersInit;
logout(): void;
}
interface LoggedOutState {
loggedIn: false;
login(password: string): Promise<boolean>;
}
export type AuthState = LoggedInState | LoggedOutState;
const AuthContext = createContext({
loggedIn: false,
login: () => {
throw new Error("No parent AuthContext found!");
},
} as AuthState);
export const AuthProvider: React.FC = (props) => {
const [loggedIn, setLoggedIn] = useState(false);
const [headers, setHeaders] = useState<HeadersInit | undefined>();
function logout() {
setLoggedIn(false);
setHeaders(undefined);
}
async function login(password: string): Promise<boolean> {
const username = process.env.NEXT_PUBLIC_EDITOR_USERNAME;
if (!username) {
throw new Error(
"Missing NEXT_PUBLIC_EDITOR_USERNAME environment variable"
);
}
const newHeaders = {
Authorization: `CustomBasic ${btoa(`${username}:${password}`)}`,
};
const res = await fetch("/api/editor/links", { headers: newHeaders });
if (res.status === 200) {
setLoggedIn(true);
setHeaders(newHeaders);
return true;
} else {
logout();
return false;
}
}
return (
<AuthContext.Provider
value={
loggedIn && headers != null
? { loggedIn, headers, logout }
: { loggedIn: false, login }
}
{...props}
/>
);
};
export function useAuth(): AuthState {
return useContext(AuthContext);
}

View File

@ -1,90 +0,0 @@
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;

View File

@ -0,0 +1,59 @@
import React, { useState } from "react";
import { useAuth } from "components/Login/authcontext";
const LoginBox: React.FC = () => {
const [password, setPassword] = useState("");
const [focused, setFocused] = useState(false);
const [loginFailed, setLoginFailed] = useState(false);
const auth = 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`;
async function handleSubmit(e: React.SyntheticEvent) {
e.preventDefault();
if (!auth.loggedIn) {
const loginSuccessful = await auth.login(password);
if (!loginSuccessful) {
setLoginFailed(true);
setPassword("");
}
}
}
return (
<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)}
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>
);
};
export default LoginBox;

View File

@ -0,0 +1,22 @@
import Image from "next/image";
const LoginHead: React.FC = () => {
return (
<div className="flex flex-col justify-center items-center space-y-10">
<div className="flex flex-row justify-center items-center space-x-5">
<Image
src="/images/csc-logo-fb-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>
);
};
export default LoginHead;

View File

@ -1,23 +1,7 @@
import { Link, Links } from "components/Links";
import React from "react";
import styles from "./styles.module.css";
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>
);
const Preview: React.FC = () => {
return <div />;
};
export default Preview;

View File

@ -1,17 +0,0 @@
.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%;
}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "next export",
"start": "next start",
"type-check": "tsc",
"format": "prettier --write './**/*'",
"format:check": "prettier --check './**/*'",

View File

@ -1,33 +1,53 @@
import Head from "next/head";
import React, { useEffect, useState } from "react";
import { AuthProvider, useAuth } from "components/Login/AuthContext";
import Login from "components/Login";
import { AuthProvider, useAuth } from "components/Login/authcontext";
import LoginHead from "components/Login/loginhead";
import LoginBox from "components/Login/loginbox";
import Analytics from "components/Analytics";
import Editor from "components/Editor";
import { EditableLink } from "components/Editor/Link";
const LoginScreen: React.FC = () => (
<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>
<LoginHead />
<div className="flex justify-center items-center px-10 py-8 bg-gray-50 border-2 border-gray-300 rounded-lg">
<LoginBox />
</div>
</div>
</div>
</div>
);
const EditorPage: React.FC = () => {
const { loggedIn, headers } = useAuth();
const auth = useAuth();
const [links, setLinks] = useState<EditableLink[]>([]);
useEffect(() => {
async function fetchLinks() {
if (!loggedIn) return;
if (!auth.loggedIn) {
return;
}
const res = await fetch("api/editor/links", { headers });
const links = await res.json();
setLinks(links);
const res = await fetch("/api/editor/links", { headers: auth.headers });
setLinks(await res.json());
}
fetchLinks();
}, [loggedIn, headers]);
}, [auth]);
return loggedIn ? (
<div>
<Analytics clicks={links.reduce((acc, curr) => acc + curr.clicks, 0)} />
return auth.loggedIn ? (
<>
<Analytics />
<Editor links={links} setLinks={setLinks} />
</div>
</>
) : (
<Login />
<LoginScreen />
);
};

View File

@ -0,0 +1,3 @@
{
"greeting": "Hello from JSON"
}

View File

@ -0,0 +1,48 @@
import React from "react";
import { GetStaticProps } from "next";
import GreetingJSON from "./hello-world.json";
import { fetchExample } from "utils/api";
interface Props {
greeting: string;
}
export const getStaticProps: GetStaticProps<Props> = async () => {
return {
props: { greeting: GreetingJSON.greeting }, // 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,
};
};
function HelloWorld({ greeting }: Props): JSX.Element {
const [greetings, setGreetings] = React.useState([greeting]);
const getMoreGreetings = async () => {
const greetingFromBackend = await fetchExample();
setGreetings([
...greetings,
`"${greetingFromBackend}" --- ${new Date().toISOString()}`,
]);
};
return (
<div style={{ padding: "50px" }}>
<button
style={{ border: "2px solid black", padding: "5px" }}
onClick={getMoreGreetings}
>
Get greeting from the server
</button>
<ol style={{ listStyleType: "decimal" }}>
{greetings.map((greeting) => (
<li key={greeting}>{greeting}</li>
))}
</ol>
</div>
);
}
export default HelloWorld;

27
frontend/pages/index.tsx Normal file
View File

@ -0,0 +1,27 @@
import React from "react";
import { GetStaticProps } from "next";
import { Link, Links } from "components/Links";
import { readFileSync } from "fs";
export const getStaticProps: GetStaticProps<Props> = async () => {
if (!process.env.LINKS_FILE) {
throw new Error("Set the LINKS_FILE environment variable");
}
const links = JSON.parse(readFileSync(process.env.LINKS_FILE).toString());
return {
props: { links },
revalidate: 1,
};
};
interface Props {
links: Link[];
}
const Home: React.FC<Props> = ({ links }) => {
return <Links links={links} />;
};
export default Home;

View File

@ -1,5 +0,0 @@
RewriteEngine On
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteRule "^api/(.*)$" "http://corn-syrup:5730/$1" [P]

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@ -1,17 +0,0 @@
@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%;
}

View File

@ -1,58 +0,0 @@
// 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"],
},
},
};

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -4,16 +4,14 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Karla";
src: local("Karla"),
url("/fonts/Karla-VariableFont_wght.ttf") format("truetype");
font-style: normal;
}
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%;
}

View File

@ -13,12 +13,10 @@ module.exports = {
},
darkMode: false, // or 'media' or 'class'
theme: {
fontFamily: {
body: ["Karla", "sans-serif"],
},
extend: {
colors: {
analytics: {
border: "#d7dce1",
"view-icon": "#39e09b",
"click-icon": "#8a86e5",
},
@ -40,20 +38,19 @@ module.exports = {
analytics: "1 0 0",
chevron: "0 0 16px",
},
borderRadius: {
preview: "3.2rem",
},
borderWidth: {
preview: "0.75rem",
},
minHeight: {
link: "48px",
},
},
minWidth: {
"9/10": "90%",
},
maxWidth: {
"6/10": "60%",
},
container: {
center: true,
},
},
variants: {
extend: {
opacity: ["disabled"],
},
extend: {},
},
plugins: [],
};

4
frontend/utils/api.ts Normal file
View File

@ -0,0 +1,4 @@
export async function fetchExample(): Promise<string> {
const response = await fetch("/api");
return await response.text();
}

View File

@ -3,7 +3,6 @@
set -e
source ./common.sh
MAIN_PATH=$(pwd)
function setup_frontend() {
prefix_stdout_stderr "${PURPLE}frontend: ${NC}"
@ -13,10 +12,6 @@ function setup_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!"
}
@ -31,9 +26,6 @@ function setup_backend() {
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
@ -44,11 +36,6 @@ function setup_backend() {
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!"
}