Compare commits

...

10 Commits

Author SHA1 Message Date
Sage Vaillancourt a8b830d3d1 Update README with new link 2023-10-29 21:59:35 -04:00
Sage Vaillancourt e418df1a72 Larger template-selector 2023-10-29 21:58:32 -04:00
Sage Vaillancourt 6669613af5 More specific <input type="text"> CSS.
Fixes odd Log Out button styling
2023-10-29 21:20:26 -04:00
Sage Vaillancourt 7348bf3ee6 Addition of a toggleable dark mode.
Other UI cleanup and consistency.
2023-10-29 21:16:11 -04:00
Sage Vaillancourt 77868b4f9b Add a basic README 2023-05-03 23:12:25 +00:00
Sage Vaillancourt b03ee285a2 Add 'Latest commit' label to /status page 2023-05-03 20:51:20 +00:00
Sage Vaillancourt 8f6a7ac2f6 Refactor some styling.
Trying to move away from so many element-specific classes.
2023-05-03 20:32:06 +00:00
Sage Vaillancourt cca15686bf Change add_letter error to a redirect 2023-05-03 20:09:12 +00:00
Sage Vaillancourt cd87e4fa29 Double-quote to prevent PROD_PORT globbing. 2022-10-03 16:40:45 -04:00
Sage Vaillancourt a407f3228c Use env var for PROD_PORT 2022-10-03 20:37:56 +00:00
7 changed files with 228 additions and 107 deletions

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# UnderCover
https://undercover.sagev.space/ is a site designed to act as a simple interface for
generating templated cover letters.
The site is built on Flask, with a very simple VanillaJS frontend, and
leverages LaTeX for document generation. User data, including logins and stored
templates, is stored in postgres, with bcrypt-hashed credentials.
## Deployment
While not implemented as part of this repo, the site itself uses a pseudo
blue/green deployment system, with Testing and Production both running on one
server. A simple script toggles the proxy between pointing visitors to the blue
or the green instance.

View File

@ -167,7 +167,7 @@ def add_letter() -> Response:
existing_letter_count = len(db.get_user_letters(user.id)) existing_letter_count = len(db.get_user_letters(user.id))
if user.in_free_tier() and existing_letter_count >= FREE_TIER_TEMPLATES: if user.in_free_tier() and existing_letter_count >= FREE_TIER_TEMPLATES:
return render_index(error=f'A maximum of {FREE_TIER_TEMPLATES} templates are available to each user.') return redirect('/?error=template_limit')
new_letter_name = f'Letter{existing_letter_count + 1}' new_letter_name = f'Letter{existing_letter_count + 1}'
default_form_json = jsonify(CLForm().to_cl_data()).get_data(True) default_form_json = jsonify(CLForm().to_cl_data()).get_data(True)
db.add_letter(user.id, new_letter_name, default_form_json) db.add_letter(user.id, new_letter_name, default_form_json)
@ -194,6 +194,9 @@ def index_get() -> Response:
form.letterName.data = 1 form.letterName.data = 1
selected_letter = request.args.get('letter_name') selected_letter = request.args.get('letter_name')
error = None
if request.args.get('error') == 'template_limit':
error = f'A maximum of {FREE_TIER_TEMPLATES} templates are available to each user.'
if selected_letter: if selected_letter:
for i, letter in enumerate(letters): for i, letter in enumerate(letters):
@ -216,7 +219,7 @@ def index_get() -> Response:
form.skillTypes.data = data['skillTypes'] form.skillTypes.data = data['skillTypes']
form.username.data = data['username'] form.username.data = data['username']
return render_index(form=form) return render_index(form=form, error=error)
@writing_blueprint.route('/reset', methods=['POST', 'GET']) @writing_blueprint.route('/reset', methods=['POST', 'GET'])
@ -284,7 +287,7 @@ def status_page() -> Response:
status_message = f"Currently running on '{os.environ.get('UNDERCOVER_SERVER_NAME')}' server" status_message = f"Currently running on '{os.environ.get('UNDERCOVER_SERVER_NAME')}' server"
git_commands = 'git log -1 --pretty=%B; git rev-parse --short HEAD' git_commands = 'git log -1 --pretty=%B; git rev-parse --short HEAD'
current_git_hash = subprocess.check_output([git_commands], shell=True, universal_newlines=True) current_git_hash = subprocess.check_output([git_commands], shell=True, universal_newlines=True)
return make_response(render_template('error.jinja2', status=200, error_text=status_message, extra_text=current_git_hash), 200) return make_response(render_template('error.jinja2', status=200, error_text=status_message, extra_text='Latest commit: ' + current_git_hash), 200)
@writing_blueprint.route('/', methods=['POST']) @writing_blueprint.route('/', methods=['POST'])

View File

@ -2,6 +2,48 @@ html {
height: 100%; height: 100%;
} }
body {
background-color: #f0f0f0;
line-height: 180%;
font-size: 110%;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
font-family: 'Barlow', sans-serif;
}
.transition, .transition * {
transition-duration: 0.5s;
transition-property: color, border-color, background-color;
}
.theme-toggler {
cursor: pointer;
}
body.dark-mode {
color: #e7e7e7;
background: linear-gradient(to bottom right, #2e2e62, #1a1226);
background-repeat: no-repeat;
background-attachment: fixed;
}
h1, h2 {
font-family: 'BarlowMedium', sans-serif;
text-align: center;
color: #111;
}
body.dark-mode h1 {
color: #e7e7e7;
}
body.dark-mode h2 {
color: #e7e7e7;
}
::placeholder { ::placeholder {
color: #999; color: #999;
} }
@ -37,26 +79,10 @@ h1:hover span.logo.right {
background-color: white; background-color: white;
} }
body {
background-color: #f0f0f0;
line-height: 180%;
font-size: 110%;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
font-family: 'Barlow', sans-serif;
}
strong { strong {
font-family: 'BarlowMedium', sans-serif; font-family: 'BarlowMedium', sans-serif;
} }
h1, h2 {
font-family: 'BarlowMedium', sans-serif;
text-align: center;
}
h1 { h1 {
user-select: none; user-select: none;
} }
@ -84,6 +110,10 @@ label {
transition-timing-function: ease-out; transition-timing-function: ease-out;
} }
body.dark-mode label {
color: #eee;
}
label:hover { label:hover {
color: white; color: white;
background-color: #222; background-color: #222;
@ -93,12 +123,38 @@ input, textarea {
font-family: 'Barlow', sans-serif; font-family: 'Barlow', sans-serif;
} }
body.dark-mode input[type="text"] {
color: #eee;
background-color: #000;
border: none;
padding: 8px;
}
body.dark-mode textarea {
color: #eee;
background-color: #000;
border: none;
padding: 12px;
}
select { select {
background-color: white;
border-radius: 0; border-radius: 0;
border-style: solid; border-style: solid;
border-width: 1px; border-width: 1px;
border-color: #ccc; padding: 10px;
font-size: 0.9em;
}
select, body.dark-mode select:hover {
background-color: white;
border-color: #8f8f9d;
color: black;
}
body.dark-mode select, select:hover {
background-color: black;
border-color: black;
color: #eee;
} }
.letter-form { .letter-form {
@ -130,46 +186,51 @@ div.user form input, label {
div.user form input { div.user form input {
font-size: 120%; font-size: 120%;
border-style: solid;
border-width: 1px;
border-color: #ddd;
padding: 0.3em;
} }
.white-black-button { .btn {
cursor: pointer;
font-weight: bold; font-weight: bold;
border-radius: 0; border-radius: 0;
border-style: none; border-style: none;
color: black; }
background-color: white;
input.btn {
padding: 0.8em 2em;
}
.color-fade {
transition-property: color, background-color; transition-property: color, background-color;
transition-duration: 135ms; transition-duration: 135ms;
transition-timing-function: ease-out; transition-timing-function: ease-out;
} }
.white-black-button:hover { .primary {
color: white; color: white;
background-color: black; background-color: black;
} }
.black-white-button { .primary:hover {
font-weight: bold;
border-radius: 0;
border-style: none;
color: white;
background-color: black;
transition-property: color, background-color;
transition-duration: 135ms;
transition-timing-function: ease-out;
width: min-content;
white-space: nowrap;
}
.black-white-button:hover {
color: black; color: black;
background-color: white; background-color: white;
} }
.secondary, body.dark-mode .secondary:hover {
color: black;
background-color: white;
border: 1px solid #8f8f9d;
}
.secondary a {
color: inherit;
}
.secondary:hover, body.dark-mode .secondary {
color: white;
background-color: black;
border: 1px solid black;
}
.logged-in { .logged-in {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -271,6 +332,10 @@ textarea {
transition-timing-function: ease-out; transition-timing-function: ease-out;
} }
body.dark-mode div.modal-content {
background-color: #111;
}
.modal-background { .modal-background {
background: none; background: none;
} }
@ -288,8 +353,13 @@ textarea {
margin-bottom: 2em; margin-bottom: 2em;
} }
.modal-close-button { .top-right {
position: absolute; position: absolute;
top: 0;
right: 0;
}
.text-button {
background: none; background: none;
border: none; border: none;
font-size: 3rem; font-size: 3rem;
@ -297,14 +367,16 @@ textarea {
color: #444; color: #444;
margin: 0.25em; margin: 0.25em;
line-height: 100%; line-height: 100%;
top: 0;
right: 0;
} }
.modal-close-button:hover { .text-button:hover {
color: #000; color: #000;
} }
body.dark-mode .text-button:hover {
color: #fff;
}
/* Small screens */ /* Small screens */
@media only screen and (max-width: 720px) { @media only screen and (max-width: 720px) {
body { body {
@ -341,9 +413,6 @@ textarea {
.bigtext { .bigtext {
min-height: 40vh; min-height: 40vh;
} }
.login-button {
padding: 0.75em;
}
} }
/* Big screens */ /* Big screens */
@ -358,6 +427,7 @@ textarea {
input, textarea { input, textarea {
margin-bottom: 0.5em; margin-bottom: 0.5em;
font-size: 105%; font-size: 105%;
padding: 4px;
} }
dd { dd {
margin-left: 0.5em; margin-left: 0.5em;
@ -372,13 +442,8 @@ textarea {
width: 50%; width: 50%;
} }
div.user form input { div.user form input {
font-size: 110%;
padding: 0.5em;
height: min-content; height: min-content;
} }
.login-button {
padding: 0;
}
.user { .user {
position: absolute; position: absolute;
top: 0.5em; top: 0.5em;
@ -415,4 +480,4 @@ textarea {
.scroll-lock { .scroll-lock {
overflow: hidden; overflow: hidden;
} }

View File

@ -38,7 +38,7 @@
{% macro modal() %} {% macro modal() %}
<div id='modal' class='modal modal-background transparent' onclick="event.target.id === 'modal' && closeModal()"> <div id='modal' class='modal modal-background transparent' onclick="event.target.id === 'modal' && closeModal()">
<div class='modal modal-content'> <div class='modal modal-content'>
<button class='modal-close-button' onclick="closeModal()">×</button> <button class='top-right text-button' onclick="closeModal()">×</button>
<h2 id='modal-title'>Login Now</h2> <h2 id='modal-title'>Login Now</h2>
<form action="/login" method="post" id="create-account-form"> <form action="/login" method="post" id="create-account-form">
<div> <div>
@ -51,7 +51,7 @@
<input id="password" maxlength="32" minlength="4" name="password" type="password"> <input id="password" maxlength="32" minlength="4" name="password" type="password">
</div> </div>
<div id="confirm-password-div"> <div id="confirm-password-div">
<div style="font-size: 80%; margin-bottom: 1.5em;">Password must be between 8 and 64 characters</div> <div style="margin-bottom: 1.5em;">Password must be between 8 and 64 characters</div>
<label id='confirm-password-label' for="confirm-password">Confirm Password: </label> <label id='confirm-password-label' for="confirm-password">Confirm Password: </label>
<input id="confirm-password" maxlength="32" minlength="4" name="confirm-password" type="password"> <input id="confirm-password" maxlength="32" minlength="4" name="confirm-password" type="password">
</div> </div>

View File

@ -3,48 +3,75 @@
{% from "_formhelpers.jinja2" import modal %} {% from "_formhelpers.jinja2" import modal %}
<head> <head>
<title>{% block title %}UnderCover{% endblock title %}</title> <title>{% block title %}UnderCover{% endblock title %}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}"> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" /> <link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<meta name="description" content="UnderCover is an easy way to generate unique cover letters, making it simple to send personalized applications to many companies quickly."> <meta name="description" content="UnderCover is an easy way to generate unique cover letters, making it simple to send personalized applications to many companies quickly.">
<meta name="viewport" content="user-scalable=no; width=device-width"> <meta name="viewport" content="user-scalable=no; width=device-width">
<script type="text/javascript"> <script type="text/javascript">
function closeModal() { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal').classList.add('transparent') const darkSystem = () => window.matchMedia('(prefers-color-scheme: dark)').matches
document.body.classList.remove('scroll-lock') const lightStored = () => localStorage.getItem('dark-mode') === 'false'
const darkStored = () => localStorage.getItem('dark-mode') === 'true'
if (!lightStored() && (darkSystem() || darkStored())) {
document.body.classList.add('dark-mode')
} }
function showModal(login, text) { const togglers = document.getElementsByClassName('theme-toggler');
document.getElementById('modal').classList.remove('transparent') for (let i = 0; i < togglers.length; i++) {
document.getElementById('modal-title').innerText = text togglers[i].addEventListener('click', () => {
document.body.classList.add('scroll-lock') document.body.classList.toggle('dark-mode')
const createAccountElements = [ const darkMode = document.body.classList.contains('dark-mode')
'confirm-password', localStorage.setItem("dark-mode", darkMode)
'confirm-password-label', })
'create-account-form-button' }
] setTimeout(() => document.body.classList.add('transition'), 600)
const loginElements = [ })
'log-in-form-button' function toggleTheme() {
] document.body.classList.toggle('dark-mode')
const visibleElements = login ? loginElements : createAccountElements }
visibleElements.forEach(element => document.getElementById(element).classList.remove('hidden')) function closeModal() {
document.getElementById('modal').classList.add('transparent')
document.body.classList.remove('scroll-lock')
}
function showModal(login, text) {
document.getElementById('modal').classList.remove('transparent')
document.getElementById('modal-title').innerText = text
document.body.classList.add('scroll-lock')
const hiddenElements = login ? createAccountElements : loginElements const createAccountElements = [
hiddenElements.forEach(element => document.getElementById(element).classList.add('hidden')) 'confirm-password',
} 'confirm-password-label',
</script> 'create-account-form-button'
<style> ]
const loginElements = [
'log-in-form-button'
]
const [visibleElements, hiddenElements] = login
? [loginElements, createAccountElements]
: [createAccountElements, loginElements]
visibleElements.forEach(element => document.getElementById(element).classList.remove('hidden'))
hiddenElements.forEach(element => document.getElementById(element).classList.add('hidden'))
}
</script>
<style>
@font-face { @font-face {
font-family: 'Barlow'; font-family: 'Barlow';
font-display: swap; font-display: swap;
src: url('/static/fonts/Barlow-Regular.ttf'); src: url('/static/fonts/Barlow-Regular.ttf');
} }
@font-face { @font-face {
font-family: 'BarlowMedium'; font-family: 'BarlowMedium';
font-display: swap; font-display: swap;
src: url('/static/fonts/Barlow-Medium.ttf'); src: url('/static/fonts/Barlow-Medium.ttf');
} }
</style> @font-face {
{% block head %}{{ head }}{% endblock head %} font-family: 'BarlowBold';
font-display: swap;
src: url('/static/fonts/Barlow-Bold.ttf');
}
</style>
{% block head %}{{ head }}{% endblock head %}
</head> </head>
<body> <body>
@ -53,20 +80,22 @@
<div class="user logged-in"> <div class="user logged-in">
<p style="margin: 0 1em 0 0;">{{ username }}</p> <p style="margin: 0 1em 0 0;">{{ username }}</p>
<form action="/logout"> <form action="/logout">
<input class="black-white-button" style="margin: 0; padding: 0.4em;" type="submit" value="Logout"> <input class="btn primary color-fade" style="font-family: 'BarlowMedium'; margin-right: 1em; margin: 0;" type="submit" value="Log Out"></input>
</form> </form>
</div> </div>
{% else %} {% else %}
<div class="user logged-out"> <div class="user logged-out">
<form action="/create_account"> <form action="/create_account">
<div class="black-white-button" style="font-family: 'BarlowMedium'; margin-right: 1em; padding: 0.4em 1em;" onclick="showModal(false, 'Create your account')">Create account</div> <div class="btn primary color-fade" style="font-family: 'BarlowMedium'; margin-right: 1em; padding: 0.4em 1em;" onclick="showModal(false, 'Create your account')">
<div class="white-black-button" style="margin: 0; padding: 0.4em 1em;" onclick="showModal(true, 'Login now')">Log in</div> Create account
</div>
<div class="btn secondary color-fade" style="margin: 0; padding: 0.4em 1em;" onclick="showModal(true, 'Login now')">Log in</div>
</form> </form>
</div> </div>
{% endif %} {% endif %}
<a href="/" style="text-decoration: none;"> <!--<a href="/" style="text-decoration: none;">-->
<h1><span class="logo left">Under</span><span class="logo right">Cover</span></h1> <h1 title="Click to toggle dark mode" class="theme-toggler"><span class="logo left">Under</span><span class="logo right">Cover</span></h1>
</a> <!--</a>-->
<h2>The secret cover letter generator</h2> <h2>The secret cover letter generator</h2>
{% if error %} {% if error %}
<div class="errors"> <div class="errors">

View File

@ -49,11 +49,12 @@
onchange="window.location = '/?letter_name=' + this.options[this.value - 1].label" onchange="window.location = '/?letter_name=' + this.options[this.value - 1].label"
) )
}} }}
<a <div class="btn secondary color-fade" style="display: flex; justify-content: center; align-items: center; padding-left: 0.75em; padding-right: 0.75em; margin-left: 0.5em;">
href="/add_letter" <a
class="white-black-button" href="/add_letter"
style="padding-left: 0.75em; padding-right: 0.75em; text-decoration: none; margin-left: 0.5em; text-align: center;" style="text-decoration: none; margin: 0; padding: 0;"
> + </a> > + </a>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

10
start
View File

@ -2,6 +2,8 @@
SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )" SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )"
source $SCRIPT_DIR/env
if ! test -f "$SCRIPT_DIR/.undercover_init_successful"; then if ! test -f "$SCRIPT_DIR/.undercover_init_successful"; then
if ! "$SCRIPT_DIR/init"; then if ! "$SCRIPT_DIR/init"; then
exit 1 exit 1
@ -13,9 +15,15 @@ if ! pip3 -V | grep -E "$SCRIPT_DIR/\.?venv"; then
source "$SCRIPT_DIR/.venv/bin/activate" source "$SCRIPT_DIR/.venv/bin/activate"
fi fi
PROD_PORT=8080
if [[ "$UNDERCOVER_PROD_PORT" != "" ]]; then
PROD_PORT="$UNDERCOVER_PROD_PORT"
fi
if [[ "$1" == "prod" ]]; then if [[ "$1" == "prod" ]]; then
echo "Starting gunicorn production server..." echo "Starting gunicorn production server..."
gunicorn -b localhost:8080 "app:create_app()" gunicorn -b "localhost:$PROD_PORT" "app:create_app()"
else else
echo "Starting local dev server..." echo "Starting local dev server..."
export FLASK_APP=app export FLASK_APP=app