feat: initialize project structure for Small

Add initial project structure for Small, a clean frontend for reading Medium articles. This setup includes:

- Basic Flask app configuration with necessary dependencies.
- RESTful endpoint to fetch and serve Medium articles.
- Templates and static assets for the user interface.
- Utility functions for parsing Medium article IDs.
- Custom error pages (404 and 500) for nicer error handling.
- Project metadata files such as .gitignore, README, LICENSE, and pyproject.toml for project setup and documentation.

This structure sets the foundation for further development and feature additions.
This commit is contained in:
Kumi 2024-09-19 09:14:05 +02:00
commit 675f5195fe
Signed by: kumi
GPG key ID: ECBCC9082395383F
22 changed files with 524 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
venv/
.venv/
__pycache__/
*.pyc
/dist/

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2024 Private.coffee Team <support@private.coffee>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# Small
Small is an alternative frontend for Medium articles, built with Flask. It allows users to read Medium articles without the clutter and distractions of the original Medium interface.
## Features
- Clean, minimalist interface for reading Medium articles
- Fetches article content directly from Medium's GraphQL API
- Parses and displays article content, including text and basic formatting
- Responsive design for comfortable reading on various devices
## Installation
1. Clone the repository:
```
git clone https://git.private.coffee/PrivateCoffee/small.git
cd small
```
2. Create a virtual environment and activate it:
```
python -m venv venv
source venv/bin/activate
```
3. Install the package:
```
pip install .
```
## Usage
1. Start the Flask development server:
```
small
```
2. Open your web browser and navigate to `http://localhost:5000`
3. To read a Medium article, replace `https://medium.com` in the article's URL with `http://localhost:5000`
For example:
- Original URL: `https://medium.com/@username/article-title-123abc`
- Small URL: `http://localhost:5000/@username/article-title-123abc`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
- Inspired by the [Scribe](https://git.sr.ht/~edwardloveall/scribe) project built with Crystal and Lucky
- Thanks to Medium for providing the content through their API
## Disclaimer
This project is not affiliated with, endorsed, or sponsored by Medium. It's an independent project created to provide an alternative reading experience for Medium content.

26
pyproject.toml Normal file
View file

@ -0,0 +1,26 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "small"
version = "0.1.0"
authors = [{ name = "Private.coffee Team", email = "support@private.coffee" }]
description = "A simple frontend for Medium"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = ["flask", "bs4", "requests"]
[project.scripts]
small = "small.app:main"
[project.urls]
"Homepage" = "https://git.private.coffee/privatecoffee/small"
"Bug Tracker" = "https://git.private.coffee/privatecoffee/small/issues"
"Source Code" = "https://git.private.coffee/privatecoffee/small"

0
src/small/__init__.py Normal file
View file

28
src/small/app.py Normal file
View file

@ -0,0 +1,28 @@
from flask import Flask
from os import environ
from .const import Config
from .views import home, article, error, proxy
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
app.register_blueprint(home.bp)
app.register_blueprint(article.bp)
app.register_blueprint(error.bp)
app.register_blueprint(proxy.bp)
return app
def main():
app = create_app()
port = int(environ.get("PORT", 8115))
app.run(port=port)
if __name__ == "__main__":
main()

6
src/small/const.py Normal file
View file

@ -0,0 +1,6 @@
import os
class Config:
MEDIUM_API_URL = "https://medium.com/_/graphql"
GITHUB_API_URL = "https://api.github.com"

28
src/small/models/nodes.py Normal file
View file

@ -0,0 +1,28 @@
from dataclasses import dataclass
from typing import List, Union
@dataclass
class Text:
content: str
@dataclass
class Image:
src: str
alt: str
width: int
height: int
@dataclass
class Paragraph:
children: List[Union[Text, Image]]
@dataclass
class Page:
title: str
author: str
created_at: str
content: List[Paragraph]

10
src/small/models/page.py Normal file
View file

@ -0,0 +1,10 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Page:
title: str
author: str
created_at: datetime
content: str

View file

@ -0,0 +1,71 @@
import requests
from flask import url_for
from small.models.nodes import Page, Paragraph, Text, Image
from datetime import datetime
class MediumClient:
@staticmethod
def get_post(post_id):
url = "https://medium.com/_/graphql"
query = (
"""
query {
post(id: "%s") {
title
createdAt
creator {
name
}
content {
bodyModel {
paragraphs {
type
text
metadata {
id
originalWidth
originalHeight
}
}
}
}
}
}
"""
% post_id
)
response = requests.post(url, json={"query": query})
data = response.json()["data"]["post"]
paragraphs = []
for p in data["content"]["bodyModel"]["paragraphs"]:
if p["type"] == "IMG":
children = [
Image(
src=url_for(
"proxy.image",
original_width=p["metadata"]["originalWidth"],
id=p["metadata"]["id"],
),
alt=p["text"],
width=p["metadata"]["originalWidth"],
height=p["metadata"]["originalHeight"],
)
]
else:
children = [Text(content=p["text"])]
paragraphs.append(Paragraph(children=children))
return Page(
title=data["title"],
author=data["creator"]["name"],
created_at=datetime.fromtimestamp(data["createdAt"] / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
),
content=paragraphs,
)

View file

@ -0,0 +1,91 @@
/* General Styles */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
a {
color: #1a73e8;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Header Styles */
header {
border-bottom: 1px solid #e0e0e0;
padding-bottom: 20px;
margin-bottom: 40px;
}
header h1 {
margin: 0;
font-size: 2.5em;
}
header nav {
margin-top: 10px;
}
header nav a {
margin-right: 15px;
}
/* Footer Styles */
footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 0.9em;
color: #666;
}
/* Article Styles */
article {
background-color: #fff;
padding: 30px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
article h1 {
font-size: 2.2em;
margin-bottom: 10px;
}
article .meta {
color: #666;
font-size: 0.9em;
margin-bottom: 20px;
}
article img {
max-width: 100%;
height: auto;
margin: 20px 0;
}
/* Error Page Styles */
.error-container {
text-align: center;
padding: 40px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.error-container h1 {
color: #d32f2f;
}
.error-container ul {
text-align: left;
display: inline-block;
}

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} - Small{% endblock %}
{% block content %}
<h1>{{ page.title }}</h1>
<p>By {{ page.author }} on {{ page.created_at }}</p>
<article>
{% for paragraph in page.content %}
<p>
{% for child in paragraph.children %}
{% if child.__class__.__name__ == 'Text' %}
{{ child.content }}
{% elif child.__class__.__name__ == 'Image' %}
<img src="{{ child.src }}" alt="{{ child.alt }}" width="{{ child.width }}" height="{{ child.height }}">
{% endif %}
{% endfor %}
</p>
{% endfor %}
</article>
{% endblock %}

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Small{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header>
<h1>Small</h1>
<nav>
<a href="{{ url_for('home.home') }}">Home</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>Small is <a href="https://git.private.coffee/PrivateCoffee/small">open source software</a> brought to you by <a href="https://private.coffee">Private.coffee</a>.</p>
</footer>
</body>
</html>

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}404 Not Found - Small{% endblock %}
{% block content %}
<div class="error-container">
<h1>404 Not Found</h1>
<p>Sorry, the page you are looking for could not be found.</p>
<p>This could be because:</p>
<ul>
<li>The URL you entered is incorrect or outdated.</li>
<li>The article you're trying to access no longer exists on Medium.</li>
<li>You are trying to access something that is not an article. Small currently only supports Medium articles, no user profiles or other pages.</li>
<li>There was an error parsing the Medium URL.</li>
</ul>
<p>You can try:</p>
<ul>
<li>Double-checking the URL for any typos.</li>
<li>Going back to <a href="{{ url_for('home.home') }}">our homepage</a> and starting over.</li>
<li>Searching for the article directly on Medium.</li>
</ul>
</div>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}500 Internal Server Error - Small{% endblock %}
{% block content %}
<div class="error-container">
<h1>500 Internal Server Error</h1>
<p>We're sorry, but something went wrong on our end.</p>
<p>This could be because:</p>
<ul>
<li>There was an issue connecting to Medium's servers.</li>
<li>Medium's API structure has changed, and we haven't updated yet.</li>
<li>There's a bug in our application.</li>
</ul>
<p>You can try:</p>
<ul>
<li>Refreshing the page.</li>
<li>Coming back later - the issue might be temporary.</li>
<li>Going back to <a href="{{ url_for('home.home') }}">our homepage</a> and trying again.</li>
</ul>
<p>If the problem persists, please contact the site administrator.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="home-container">
<h1>Welcome to Small</h1>
<p><strong>Smaller than Medium</strong></p>
<p>To use Small, simply replace "medium.com" with "{{ url_for("home.home", _external=True) }}" in any Medium article URL.</p>
<h2>Example:</h2>
<p>
<strong>Original:</strong> https://medium.com/@username/article-title-123abc<br>
<strong>Small:</strong> https://{{ url_for("home.home", _external=True) }}@username/article-title-123abc
</p>
</div>
{% endblock %}

View file

@ -0,0 +1,5 @@
import re
def parse_article_id(url):
match = re.search(r'[a-f0-9]{12}$', url)
return match.group(0) if match else None

View file

@ -0,0 +1,6 @@
import small.views.article as article
import small.views.error as error
import small.views.home as home
import small.views.proxy as proxy
__all__ = ["article", "error", "home", "proxy"]

View file

@ -0,0 +1,20 @@
from flask import Blueprint, render_template, abort
from small.services.medium_client import MediumClient
from small.utils.parse_article_id import parse_article_id
bp = Blueprint("articles", __name__)
@bp.route("/<path:article_url>")
def article(article_url):
article_id = parse_article_id(article_url)
if not article_id:
abort(404)
try:
page = MediumClient.get_post(article_id)
return render_template("article.html", page=page)
except Exception as e:
print(f"Error fetching article: {str(e)}")
abort(500)

13
src/small/views/error.py Normal file
View file

@ -0,0 +1,13 @@
from flask import Blueprint, render_template
bp = Blueprint("errors", __name__)
@bp.app_errorhandler(404)
def not_found_error(error):
return render_template("errors/404.html"), 404
@bp.app_errorhandler(500)
def internal_error(error):
return render_template("errors/500.html"), 500

7
src/small/views/home.py Normal file
View file

@ -0,0 +1,7 @@
from flask import Blueprint, render_template
bp = Blueprint('home', __name__)
@bp.route('/')
def home():
return render_template('home.html')

15
src/small/views/proxy.py Normal file
View file

@ -0,0 +1,15 @@
from flask import Blueprint, abort
from requests import get
bp = Blueprint("proxy", __name__)
@bp.route("/image/<int:original_width>/<id>")
def image(original_width, id):
try:
response = get(f"https://miro.medium.com/max/{original_width}/{id}")
return response.content, response.status_code, response.headers.items()
except Exception as e:
print(f"Error fetching image: {str(e)}")
abort(500)