Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

21 changed files with 410 additions and 516 deletions

View file

@ -1,3 +0,0 @@
PORT=8002
UWSGI_PROCESSES=4
UWSGI_THREADS=4

View file

@ -1,33 +0,0 @@
name: Docker CI/CD
on:
push:
tags:
- "*"
jobs:
docker:
name: Docker Build and Push to Docker Hub
container:
image: node:20-bookworm
steps:
- name: Install dependencies
run: |
apt update
apt install -y docker.io
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to Docker Hub
uses: docker/build-push-action@v5
with:
push: true
tags: |
privatecoffee/structables:latest
privatecoffee/structables:${{ env.GITHUB_REF_NAME }}

18
.gitignore vendored
View file

@ -1,13 +1,7 @@
.env
.vscode
__pycache__/
docker-compose.yml
privacy.md
privacy.txt
*.pyc
venv/
# Special rules needed for building the Docker image
/dist/*
!/dist/css/
!/dist/css/*
*.pyc
__pycache__/
.vscode
privacy.txt
privacy.md
/dist/

View file

@ -1,21 +0,0 @@
FROM alpine:3.20
ENV APP_ENV=/opt/venv
ENV PATH="${APP_ENV}/bin:$PATH"
RUN apk add --no-cache py3-pip uwsgi-python3 && \
python3 -m venv $APP_ENV
COPY . /app
RUN $APP_ENV/bin/pip install --no-cache-dir pip && \
$APP_ENV/bin/pip install /app && \
adduser -S -D -H structables
COPY entrypoint.sh /entrypoint.sh
EXPOSE 8002
USER structables
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -13,22 +13,11 @@ An open source alternative front-end to Instructables. This is a fork of <a href
## Instances
| URL | Provided by | Country | Comments |
| ----------------------------------------------------------------------- | ----------------------------------------------- | ------- | -------- |
| [structables.private.coffee](https://structables.private.coffee/) | [Private.coffee](https://private.coffee/) | Austria | |
| [structables.bloat.cat](https://structables.bloat.cat/) | [Bloat.cat](https://bloat.cat) | Germany | |
| [structables.darkness.services](https://structables.darkness.services/) | [Darkness.services](https://darkness.services/) | USA | |
| URL | Provided by | Country | Comments |
| -------------------------------------------------------------------------- | ----------------------------------------- | ------- | -------- |
| [https://structables.private.coffee/](https://structables.private.coffee/) | [Private.coffee](https://private.coffee/) | Austria | |
### Tor Hidden Services
| URL | Provided by | Country | Comments |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------- | -------- |
| [structables.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion](http://structables.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion/) | [Private.coffee](https://private.coffee/) | Austria | |
| [structables.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion](http://structables.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion/) | [Darkness.services](https://darkness.services/) | USA | |
### Adding Your Instance
To add your own instance to this list, please open a pull request or issue, see below.
To add your own instance to this list, please open a pull request or issue.
## Opening Issues
@ -38,7 +27,7 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
## Run your own instance
### Production: Manual
### Production
1. Create a virtual environment: `python3 -m venv venv`
2. Activate the virtual environment: `source venv/bin/activate`
@ -46,21 +35,6 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
4. Run `uwsgi --plugin python3 --http-socket 0.0.0.0:8002 --module structables.main:app --processes 4 --threads 4`
5. Point your reverse proxy to http://localhost:8002 and (optionally) serve static files from the `venv/lib/pythonX.XX/site-packages/structables/static` directory
6. Connect to your instance under your domain
7. Ensure that `/cron/` is executed at regular intervals so that the app updates its cached data.
### Production: Docker
1. Copy `.env.example` to `.env` and adjust the settings as necessary
2. Copy `docker-compose-example.yml` to `docker-compose.yml` and adjust it as necessary, for example modifying resource limits or changing the port/host configuration
3. Build and run the Docker container:
```sh
docker-compose up [-d]
```
4. Point your reverse proxy to http://127.0.0.1:8002 (or your chosen port, if you modified it) and (optionally) serve static files from `structables/static`
5. Connect to your instance under your domain
6. Ensure that `/cron/` is executed at regular intervals so that the app updates its cached data.
### Development
@ -71,17 +45,6 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
5. Run `structables`
6. Connect to http://localhost:8002
### Environment Variables
Structables supports the use of the following environment variables for configuration:
- `STRUCTABLES_PORT`: The port to listen on (default: 8002)
- `STRUCTABLES_LISTEN_HOST`: The host/IP address to listen on (default: 127.0.0.1)
- `STRUCTABLES_INVIDIOUS`: The hostname of an Invidious instance to use for embedded YouTube videos (currently not recommended due to YouTube blocks)
- `STRUCTABLES_UNSAFE`: If set, allow embedding untrusted iframes (if unset, display a warning and allow loading the content manually)
- `STRUCTABLES_PRIVACY_FILE`: The path to a text file or Markdown file (with .md suffix) to use for the Privacy Policy page (if unset, try `privacy.txt` or `privacy.md` in the working directory, or fall back to a generic message)
- `STRUCTABLES_DEBUG`: If set, log additional debug information to stdout
## License
This project, as well as the two projects it is based on, are licensed under the GNU Affero General Public License v3. See the [LICENSE](LICENSE) file for more information.

View file

@ -1,18 +0,0 @@
services:
structables:
container_name: structables
restart: unless-stopped
build: .
ports:
- "127.0.0.1:8002:8002"
env_file: .env
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
deploy:
resources:
limits:
cpus: '0.5'
memory: 300M

View file

@ -1,18 +0,0 @@
services:
structables:
container_name: structables
restart: unless-stopped
image: privatecoffee/structables:latest
ports:
- "127.0.0.1:8002:8002"
env_file: .env
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
deploy:
resources:
limits:
cpus: '0.5'
memory: 300M

View file

@ -1,18 +0,0 @@
#!/bin/sh
args="--plugin python3 \
--http-socket 0.0.0.0:$PORT \
--master \
--module structables.main:app \
-H /opt/venv"
if [ "$UWSGI_PROCESSES" ]
then
args="$args --processes $UWSGI_PROCESSES"
fi
if [ "$UWSGI_THREADS" ]
then
args="$args --threads $UWSGI_THREADS"
fi
exec /usr/sbin/uwsgi $args

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "structables"
version = "0.3.15"
version = "0.3.6"
authors = [
{ name="Private.coffee Team", email="support@private.coffee" },
]

View file

@ -1,8 +1,6 @@
#!/usr/bin/env python
from flask import Flask
import threading
import time
from .config import Config
from .routes import init_routes
@ -14,30 +12,12 @@ app.config.from_object(Config)
app.typesense_api_key = get_typesense_api_key()
init_routes(app)
update_data(app)
def background_update_data(app):
"""Runs the update_data function every 5 minutes.
This replaces the need for a cron job to update the data.
Args:
app (Flask): The Flask app instance.
"""
while True:
update_data(app)
time.sleep(300)
def main():
threading.Thread(target=background_update_data, args=(app,), daemon=True).start()
app.run(
port=app.config["PORT"],
host=app.config["LISTEN_HOST"],
debug=app.config["DEBUG"],
)
app.run(port=app.config['PORT'], host=app.config['LISTEN_HOST'], debug=app.config['DEBUG'])
if __name__ == "__main__":
main()
# Initialize data when the server starts
update_data(app)

View file

@ -1,133 +1,105 @@
from flask import render_template, request, abort, url_for
from urllib.request import urlopen, Request
from flask import render_template, request, abort
from urllib.request import urlopen
from urllib.error import HTTPError
from ..utils.helpers import proxy
from bs4 import BeautifulSoup
import json
def init_contest_routes(app):
@app.route("/contest/archive/")
def route_contest_archive():
# Default pagination settings
limit = 10
page = request.args.get("page", default=1, type=int)
offset = (page - 1) * limit
page = 1
if request.args.get("page") is not None:
page = request.args.get("page")
try:
# Fetch data using urlopen
url = f"https://www.instructables.com/json-api/getClosedContests?limit={limit}&offset={offset}"
response = urlopen(url)
data = json.loads(response.read().decode())
data = urlopen(f"https://www.instructables.com/contest/archive/?page={page}")
except HTTPError as e:
abort(e.code)
except Exception as e:
abort(500) # Handle other exceptions like JSON decode errors
contests = data.get("contests", [])
full_list_size = data.get("fullListSize", 0)
soup = BeautifulSoup(data.read().decode(), "html.parser")
main = soup.select("div#contest-archive-wrapper")[0]
contest_count = main.select("p.contest-count")[0].text
contest_list = []
for contest in contests:
contest_details = {
"title": contest["title"],
"link": url_for("route_contest", contest=contest["urlString"]),
"deadline": contest["deadline"],
"startDate": contest["startDate"],
"numEntries": contest["numEntries"],
"state": contest["state"],
"bannerUrl": proxy(contest["bannerUrlMedium"]),
}
contest_list.append(contest_details)
for index, year in enumerate(main.select("div.contest-archive-list h2")):
year_list = main.select(
"div.contest-archive-list div.contest-archive-list-year"
)[index]
year_name = year.text
month_list = []
for month in year_list.select("div.contest-archive-list-month"):
month_name = month.select("h3")[0].text
month_contest_list = []
for p in month.select("p"):
date = p.select("span")[0].text
link = p.select("a")[0].get("href")
title = p.select("a")[0].text
month_contest_list.append([date, link, title])
month_list.append([month_name, month_contest_list])
contest_list.append([year_name, month_list])
# Calculate total pages
total_pages = (full_list_size + limit - 1) // limit
# Create pagination
pagination = {
"current_page": page,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"limit": limit,
}
pagination = main.select("nav.pagination ul.pagination")[0]
return render_template(
"archives.html",
title=f"Contest Archives (Page {page})",
page=page,
contest_count=contest_count,
pagination=pagination,
contest_list=contest_list,
)
def get_entries(contest):
base_url = f"https://www.instructables.com/api_proxy/search/collections/projects/documents/search"
headers = {"x-typesense-api-key": app.typesense_api_key}
page, per_page = 1, 100
all_entries = []
while True:
try:
url = f"{base_url}?q=*&filter_by=contestPath:{contest}&sort_by=contestEntryDate:desc&per_page={per_page}&page={page}"
request = Request(url, headers=headers)
response = urlopen(request)
data = json.loads(response.read().decode())
except HTTPError as e:
abort(e.code)
hits = data.get("hits", [])
if not hits:
break
all_entries.extend(hits)
if len(hits) < per_page:
break
page += 1
return all_entries
@app.route("/contest/<contest>/")
def route_contest(contest):
try:
data = urlopen(f"https://www.instructables.com/contest/{contest}/")
html = data.read().decode()
soup = BeautifulSoup(html, "html.parser")
title_tag = soup.find("h1")
title = title_tag.get_text() if title_tag else "Contest"
img_tag = soup.find("img", alt=lambda x: x and "Banner" in x)
img = img_tag.get("src") if img_tag else "default.jpg"
entry_count = len(get_entries(contest))
prizes_items = soup.select("article")
prizes = len(prizes_items) if prizes_items else 0
overview_section = soup.find("section", id="overview")
info = (
overview_section.decode_contents()
if overview_section
else "No Overview"
)
except HTTPError as e:
abort(e.code)
soup = BeautifulSoup(data.read().decode(), "html.parser")
title = soup.select('meta[property="og:title"]')[0].get("content")
body = soup.select("div#contest-wrapper")[0]
img = proxy(body.select("div#contest-masthead img")[0].get("src"))
entry_count = body.select("li.entries-nav-btn")[0].text.split(" ")[0]
prizes = body.select("li.prizes-nav-btn")[0].text.split(" ")[0]
info = body.select("div.contest-body-column-left")[0]
info.select("div#site-announcements-page")[0].decompose()
info.select("h3")[0].decompose()
info.select("div#contest-body-nav")[0].decompose()
info = str(info).replace("https://www.instructables.com", "/")
body.select("span.contest-entity-count")[0].text
entry_list = []
entries = get_entries(contest)
for entry in entries:
doc = entry["document"]
entry_details = {
"link": url_for("route_article", article=doc["urlString"]),
"entry_img": doc["coverImageUrl"],
"entry_title": doc["title"],
"author": doc["screenName"],
"author_link": url_for("route_member", member=doc["screenName"]),
"channel": doc["channel"][0],
"channel_link": f"/{doc['primaryClassification']}",
"views": doc.get("views", 0),
for entry in body.select("div.contest-entries-list div.contest-entries-list-ible"):
link = entry.a["href"]
entry_img = proxy(entry.select("a noscript img")[0].get("src"))
entry_title = entry.select("a.ible-title")[0].text
author = entry.select("div span.ible-author a")[0].text
author_link = entry.select("div span.ible-author a")[0].get("href")
channel = entry.select("div span.ible-channel a")[0].text
channel_link = entry.select("div span.ible-channel a")[0].get("href")
views = entry.select(".ible-views")[0].text
entry_list.append(
{
"link": link,
"entry_img": entry_img,
"entry_title": entry_title,
"author": author,
"author_link": author_link,
"channel": channel,
"channel_link": channel_link,
"views": views,
}
entry_list.append(entry_details)
)
return render_template(
"contest.html",
@ -142,33 +114,64 @@ def init_contest_routes(app):
@app.route("/contest/")
def route_contests():
try:
# Fetch current contests from the JSON API
response = urlopen(
"https://www.instructables.com/json-api/getCurrentContests?limit=50&offset=0"
)
data = json.loads(response.read().decode())
data = urlopen("https://www.instructables.com/contest/")
except HTTPError as e:
abort(e.code)
except Exception as e:
abort(500) # Handle other exceptions such as JSON decode errors
contests = data.get("contests", [])
contest_list = []
for contest in contests:
contest_details = {
"link": url_for("route_contest", contest=contest["urlString"]),
"img": proxy(contest["bannerUrlMedium"]),
"alt": contest["title"],
"title": contest["title"],
"deadline": contest["deadline"],
"prizes": contest["prizeCount"],
"entries": contest["numEntries"],
}
contest_list.append(contest_details)
soup = BeautifulSoup(data.read().decode(), "html.parser")
contest_count = str(soup.select("p.contest-count")[0])
contests = []
for contest in soup.select("div#cur-contests div.row-fluid div.contest-banner"):
link = contest.select("div.contest-banner-inner a")[0].get("href")
img = proxy(contest.select("div.contest-banner-inner a img")[0].get("src"))
alt = contest.select("div.contest-banner-inner a img")[0].get("alt")
deadline = contest.select("span.contest-meta-deadline")[0].get("data-deadline")
prizes = contest.select("span.contest-meta-count")[0].text
entries = contest.select("span.contest-meta-count")[1].text
contests.append(
{
"link": link,
"img": img,
"alt": alt,
"deadline": deadline,
"prizes": prizes,
"entries": entries,
}
)
closed = []
for display in soup.select("div.contest-winner-display"):
link = display.select("div.contest-banner-inner a")[0].get("href")
img = proxy(display.select("div.contest-banner-inner a img")[0].get("src"))
alt = display.select("div.contest-banner-inner a img")[0].get("alt")
featured_items = []
for featured_item in display.select("ul.featured-items li"):
item_link = featured_item.select("div.ible-thumb a")[0].get("href")
item_img = proxy(featured_item.select("div.ible-thumb a img")[0].get("src"))
item_title = featured_item.select("a.title")[0].text
item_author = featured_item.select("a.author")[0].text
item_author_link = featured_item.select("a.author")[0].get("href")
featured_items.append(
{
"link": item_link,
"img": item_img,
"title": item_title,
"author": item_author,
"author_link": item_author_link,
}
)
closed.append(
{"link": link, "img": img, "alt": alt, "featured_items": featured_items}
)
return render_template(
"contests.html",
title="Contests",
contest_count=len(contest_list),
contests=contest_list,
)
contest_count=contest_count,
contests=contests,
closed=closed,
)

View file

@ -158,54 +158,49 @@ def init_main_routes(app):
for file in step["files"]:
if file["image"] and "embedType" not in "file":
step_imgs.append(
{
"src": proxy(file["downloadUrl"], file["name"]),
"alt": file["name"],
}
{"src": proxy(file["downloadUrl"], file["name"]), "alt": file["name"]}
)
elif not file["image"]:
if "downloadUrl" in file.keys():
step_downloads.append(
{
"src": proxy(file["downloadUrl"], file["name"]),
"name": file["name"],
}
step_downloads.append(
{
"src": proxy(file["downloadUrl"], file["name"]),
"name": file["name"],
}
)
else: # Leaves us with embeds
embed_code = file["embedHtmlCode"]
soup = BeautifulSoup(embed_code, "html.parser")
iframe = soup.select("iframe")[0]
src = iframe.get("src")
if src.startswith("https://content.instructables.com"):
src = src.replace(
"https://content.instructables.com",
f"/proxy/?url={src}",
)
else: # Leaves us with embeds
embed_code = file["embedHtmlCode"]
soup = BeautifulSoup(embed_code, "html.parser")
iframe = soup.select("iframe")[0]
src = iframe.get("src")
if src.startswith("https://content.instructables.com"):
src = src.replace(
"https://content.instructables.com",
f"/proxy/?url={src}",
)
elif app.config["INVIDIOUS"] and src.startswith(
"https://www.youtube.com"
):
src = src.replace(
"https://www.youtube.com",
app.config["INVIDIOUS"],
)
elif not app.config["UNSAFE"]:
src = "/iframe/?url=" + quote(src)
step_iframes.append(
{
"src": src,
"width": file.get("width"),
"height": file.get("height"),
}
elif app.config["INVIDIOUS"] and src.startswith(
"https://www.youtube.com"
):
src = src.replace(
"https://www.youtube.com", app.config["INVIDIOUS"]
)
elif not app.config["UNSAFE"]:
src = "/iframe/?url=" + quote(src)
step_iframes.append(
{
"src": src,
"width": file.get("width"),
"height": file.get("height"),
}
)
step_text = step["body"]
step_text = step_text.replace(
"https://content.instructables.com",
@ -357,16 +352,17 @@ def init_main_routes(app):
elif pathlib.Path("privacy.txt").exists():
path = "privacy.txt"
if path:
try:
with pathlib.Path(path).open() as f:
content = f.read()
try:
with pathlib.Path(path).open() as f:
content = f.read()
if path.endswith(".md"):
content = Markdown().convert(content)
print(path, content)
except OSError:
pass
if path.endswith(".md"):
content = Markdown().convert(content)
except OSError:
pass
return render_template(
"privacypolicy.html", title="Privacy Policy", content=content

View file

@ -1,32 +1,14 @@
from flask import render_template, abort
from urllib.request import urlopen
from urllib.error import HTTPError
from urllib.parse import quote
from ..utils.helpers import proxy, member_header
from bs4 import BeautifulSoup
from urllib.request import Request
def init_member_routes(app):
"""This function initializes all the routes related to Instructables member profiles.
Args:
app (Flask): The Flask app instance.
"""
@app.route("/member/<member>/instructables/")
def route_member_instructables(member):
"""Route to display a member's Instructables.
Args:
member (str): The member's username.
Returns:
Response: The rendered HTML page.
"""
member = quote(member)
try:
data = urlopen(
f"https://www.instructables.com/member/{member}/instructables/"
@ -73,18 +55,13 @@ def init_member_routes(app):
@app.route("/member/<member>/")
def route_member(member):
"""Route to display a member's profile.
headers = {
"User-Agent": "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
}
Args:
member (str): The member's username.
Returns:
Response: The rendered HTML page.
"""
member = quote(member)
request = Request(f"https://www.instructables.com/member/{member}/")
request = Request(
f"https://www.instructables.com/member/{member}/", headers=headers
)
try:
data = urlopen(request)
@ -126,9 +103,9 @@ def init_member_routes(app):
"div.achievements-section.main-achievements.contest-achievements div.achievement-item:not(.two-column-filler)"
):
try:
ach_title = ach.select(
"div.achievement-info span.achievement-title"
)[0].text
ach_title = ach.select("div.achievement-info span.achievement-title")[
0
].text
ach_desc = ach.select(
"div.achievement-info span.achievement-description"
)[0].text

View file

@ -130,67 +130,144 @@ blockquote {
}
}
.contest-list {
display: flex;
flex-direction: column;
gap: 20px;
.ibles {
display: inline-block;
vertical-align: top;
}
.contest-item {
background-color: #fff;
padding: 20px;
.ible-small {
font-size: 0.7em;
font-weight: thin;
line-height: 1em;
}
.step-section {
background-color: var(--primary-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px var(--shadow-color);
padding: 1.5rem;
}
.contest-item img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin-top: 10px;
.step-header h2 {
font-size: 2em;
color: var(--heading-color);
}
.pagination {
.step-images img,
.step-videos video,
.step-iframes iframe {
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow-color);
}
.step-text {
font-size: 1.1em;
}
.reply-button,
.replies {
display: none;
}
.reply-button+label {
position: relative;
display: block;
cursor: pointer;
}
input.reply-button:checked+label+.replies {
display: flex;
justify-content: center;
padding: 10px 0;
list-style-type: none;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.pagination-list {
display: flex;
align-items: center;
gap: 10px;
}
.pagination-list li {
.member-list {
display: inline-block;
max-width: 200px;
vertical-align: top;
}
.pagination-list a {
color: var(--main-color);
.ible-list-item {
display: inline-block;
max-width: 350px;
vertical-align: top;
margin-bottom: 2rem;
}
.contest-list-item {
display: inline-block;
max-width: 500px;
vertical-align: top;
margin-bottom: 2rem;
}
.archive-month-wrapper {
display: inline-block;
width: 30vw;
vertical-align: top;
}
.archive-month {
display: flex;
flex-direction: column;
gap: -10px;
margin-bottom: 1rem;
justify-content: space-between;
}
.archive {
margin-bottom: -20px;
}
ul.pagination {
display: flex;
justify-content: space-around;
padding: 0 33vw;
list-style-type: none;
align-items: center;
}
ul.pagination li.active a,
ul.pagination li.disabled a,
ul.pagination li.active a:hover,
ul.pagination li.disabled a:hover {
color: #bbc2cf;
text-decoration: none;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.pagination-list a:hover {
border-color: var(--link-color);
color: var(--link-color);
.closed-contest-contest {
object-fit: cover;
width: 33vw;
height: 15vw;
display: inline-block;
vertical-align: top;
padding: 0 10px;
}
.pagination-list li.active a {
background-color: var(--link-color);
color: #fff;
border-color: var(--link-color);
.closed-contest-winner,
.closed-contest-winner-img {
width: 15vw;
display: inline-block;
vertical-align: top;
padding: 0 10px;
font-size: 0.8em;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.pagination-list li.disabled a {
color: #ccc;
cursor: not-allowed;
.sitemap-group {
margin-top: 2em;
display: inline-block;
width: 30vw;
text-align: left;
vertical-align: top;
}
.sitemap-group h2 {
text-align: center;
}
.container {
@ -203,6 +280,7 @@ header {
.go_here_link {
background-color: #4caf50;
/* Green */
border: none;
color: white;
padding: 15px 32px;
@ -221,6 +299,10 @@ header {
overflow-wrap: break-word;
}
.navbar-logo {
height: 64px;
}
.img-fluid {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@ -240,17 +322,7 @@ iframe {
color: #6c757d !important;
}
.sitemap-group {
margin-top: 2em;
display: inline-block;
width: 30vw;
text-align: left;
vertical-align: top;
}
.sitemap-group h2 {
text-align: center;
}
.sitemap-group .card {
background-color: var(--primary-bg);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,38 +1,31 @@
{% extends "base.html" %}
{% block content %}
<center>
<h1>Past Contests</h1>
<p>Total Contests: {{ pagination.total_pages * pagination.limit }}</p>
<p><a href="/contest/">See running contests</a></p>
</center>
<div class="contest-list">
{% for contest in contest_list %}
<div class="contest-item">
<hr>
<h2>{{ contest.title }}</h2>
<p>
<a href="{{ contest.link }}">{{ contest.title }}</a><br>
Start Date: {{ contest.startDate }}<br>
Deadline: {{ contest.deadline }}<br>
Entries: {{ contest.numEntries }}<br>
Status: {{ contest.state }}<br>
</p>
<img src="{{ contest.bannerUrl }}" alt="{{ contest.title }} banner" style="max-width:100%;">
<hr>
</div>
{% endfor %}
</div>
<hr>
<div class="pagination">
<ul class="pagination-list">
{% if pagination.has_prev %}
<li><a href="?page={{ pagination.current_page - 1 }}">Previous</a></li>
{% endif %}
<li>Page {{ pagination.current_page }} of {{ pagination.total_pages }}</li>
{% if pagination.has_next %}
<li><a href="?page={{ pagination.current_page + 1 }}">Next</a></li>
{% endif %}
</ul>
</div>
{% endblock %}
<center>
<h1>Past Contests</h1>
<p>{{ contest_count }}</p>
<p><a href="/contest/">See running contests</a></p>
</center>
{% for year in contest_list %}
<div class="archive-year">
<hr>
<h2>{{ year[0] }}</h2>
<hr>
{% for month in year[1] %}
<div class="archive-month-wrapper">
<div class="archive-month">
<h3>{{ month[0] }}</h3>
{% for contest in month[1] %}
<div class="archive">
<p>{{ contest[0] }}<a href="{{ contest[1] }}">{{ contest[2] }}</a></p>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<br>
</div>
{% endfor %}
<hr>
{{ pagination|safe }}
{% endblock %}

View file

@ -1,23 +1,23 @@
{% extends "base.html" %}
{% block content %}
<center>
<h1>{{ title }}</h1>
<img src="{{ img }}" alt="{{ title }}" style="max-width:98vw;">
<p>{{ entry_count }} Entries, {{ prizes }} Prizes</p>
<br>
{{ info|safe }}
<div class="ible-list">
{% for ible in entry_list %}
<div class="ible-list-item">
<a href="{{ ible.link }}" style="color:#bbc2cf;">
<img style="max-width:350px;" src="{{ ible.entry_img }}" alt="{{ ible.entry_title }}">
<p>{{ ible.entry_title }}</p>
</a>
<p>by <a href="{{ ible.author_link }}">{{ ible.author }}</a> in <a href="{{ ible.channel_link }}">{{ ible.channel }}</a></p>
<p>{{ ible.views }} Views</p>
</div>
{% endfor %}
</div>
</center>
{% endblock %}
<center>
<img src="{{ img }}" alt="{{ title }}" style="max-width:98vw;">
<p>{{ entry_count }} Entries, {{ prizes }} Prizes</p>
<br>
{{ info|safe }}
<div class="ible-list">
{% for ible in entries %}
<div class="ible-list-item">
<a href="{{ ible.link }}" style="color:#bbc2cf;">
<img style="max-width:350px;" src="{{ ible.entry_img }}" alt="{{ ible.entry_title }}">
<p>{{ ible.entry_title }}</p>
</a>
<p>by <a href="{{ ible.author_link }}">{{ ible.author }}</a> in <a href="{{ ible.channel_link }}">{{ ible.channel }}</a></p>
<p>{{ ible.views }} Views</p>
</div>
{% endfor %}
</div>
</center>
{% endblock %}

View file

@ -1,21 +1,40 @@
{% extends "base.html" %}
{% block content %}
<center>
<h1>{{ title }}</h1>
<p>Total Running Contests: {{ contest_count }}</p>
<br>
<div class="contest-list">
{% for contest in contests %}
<div class="contest-list-item">
<a href="{{ contest.link }}">
<img src="{{ contest.img }}" alt="{{ contest.alt }}">
</a>
<h2>{{ contest.title }}</h2>
<p>Closes: {{ contest.deadline }}</p>
<p class="ible-small">{{ contest.prizes }} Prizes, {{ contest.entries }} Entries</p>
</div>
{% endfor %}
</div>
</center>
{% endblock %}
<center>
<h1>{{ title }}</h1>
{{ contest_count|safe }}
<br>
<div class="contest-list">
{% for contest in contests %}
<div class="contest-list-item">
<a href="{{ contest.link }}">
<img src="{{ contest.img }}" alt="{{ contest.alt }}" style="max-width:500px;">
</a>
<p>Closes {{ contest.deadline }}</p>
<p class="ible-small">{{ contest.prizes }} Prizes, {{ contest.entries }} Entries</p>
</div>
{% endfor %}
</div>
<div class="closed-contests" id="contest-winners">
<h2>Winner's Circle</h2>
{% for closed in closed %}
<div class="closed-contest">
<a href="{{ closed.link }}"><img class="closed-contest-contest" src="{{ closed.img }}"
alt="{{ closed.alt }}"></a>
{% for featured_items in closed.featured_items %}
<div class="closed-contest-winner">
<a href="{{ featured_items.link }}">
<img class="closed-contest-winner-img" src="{{ featured_items.img }}"
alt="{{ featured_items.title}}">
<br>
<b>{{ featured_items.title }}</b>
</a>
<p>by <a href="{{ featured_items.author_link }}">{{ featured_items.author }}</a></p>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</center>
{% endblock %}

View file

@ -4,7 +4,7 @@
<center>
<img width=150px height=150px style="display:inline-block;" src="{{ header_content.avatar }}" alt="{{ header_content.title }}">
<h1>{{ header_content.title }}</h1>
<span>{% if header_content.location %}{{ header_content.location }}&nbsp;&nbsp;{% endif %}</span>
<span>{{ header_content.location }}&nbsp;&nbsp;</span>
<span>{{ header_content.signup }}</span>
<br>
<span>{{ header_content.instructables }} Instructables&nbsp;&nbsp;</span>

View file

@ -4,7 +4,7 @@
<center>
<img width=150px height=150px style="display:inline-block;" src="{{ header_content.avatar }}" alt="{{ header_content.title }}">
<h1>{{ header_content.title }}</h1>
<span>{% if header_content.location %}{{ header_content.location }}&nbsp;&nbsp;{% endif %}</span>
<span>{{ header_content.location }}&nbsp;&nbsp;</span>
<span>{{ header_content.signup }}</span>
<br>
<span>{{ header_content.instructables }} Instructables&nbsp;&nbsp;</span>