snapshare 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snapshare/__init__.py +0 -0
- snapshare/app.py +78 -0
- snapshare/storage.py +45 -0
- snapshare/templates/gallery.html +145 -0
- snapshare/templates/index.html +176 -0
- snapshare-0.2.2.dist-info/METADATA +99 -0
- snapshare-0.2.2.dist-info/RECORD +9 -0
- snapshare-0.2.2.dist-info/WHEEL +5 -0
- snapshare-0.2.2.dist-info/top_level.txt +1 -0
snapshare/__init__.py
ADDED
|
File without changes
|
snapshare/app.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from flask import Flask, abort, redirect, render_template, request, url_for
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from snapshare import storage
|
|
10
|
+
|
|
11
|
+
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
|
12
|
+
MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20 MB
|
|
13
|
+
ALLOWED_IPS = {ip.strip() for ip in os.environ.get("ALLOWED_IPS", "").split(",") if ip.strip()}
|
|
14
|
+
|
|
15
|
+
app = Flask(__name__)
|
|
16
|
+
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.before_request
|
|
20
|
+
def restrict_ip():
|
|
21
|
+
if not ALLOWED_IPS:
|
|
22
|
+
return
|
|
23
|
+
client_ip = request.headers.get("Fly-Client-IP", request.remote_addr)
|
|
24
|
+
if client_ip not in ALLOWED_IPS:
|
|
25
|
+
abort(403)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def allowed_file(filename: str) -> bool:
|
|
29
|
+
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.route("/")
|
|
33
|
+
def index():
|
|
34
|
+
return render_template("index.html")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.route("/upload", methods=["POST"])
|
|
38
|
+
def upload():
|
|
39
|
+
file = request.files.get("photo")
|
|
40
|
+
if not file or file.filename == "":
|
|
41
|
+
return render_template("index.html", error="No file selected.")
|
|
42
|
+
|
|
43
|
+
if not allowed_file(file.filename):
|
|
44
|
+
return render_template("index.html", error="Only image files are allowed.")
|
|
45
|
+
|
|
46
|
+
suffix = Path(file.filename).suffix.lower()
|
|
47
|
+
key = f"{uuid.uuid4().hex}{suffix}"
|
|
48
|
+
raw = io.BytesIO(file.read())
|
|
49
|
+
|
|
50
|
+
# Normalise orientation from EXIF so browsers display it correctly
|
|
51
|
+
try:
|
|
52
|
+
with Image.open(raw) as img:
|
|
53
|
+
img = img.convert("RGB")
|
|
54
|
+
exif = img.getexif()
|
|
55
|
+
orientation = exif.get(274) # 274 = Orientation tag
|
|
56
|
+
rotations = {3: 180, 6: 270, 8: 90}
|
|
57
|
+
if orientation in rotations:
|
|
58
|
+
img = img.rotate(rotations[orientation], expand=True)
|
|
59
|
+
out = io.BytesIO()
|
|
60
|
+
img.save(out, "JPEG", quality=90)
|
|
61
|
+
out.seek(0)
|
|
62
|
+
key = f"{uuid.uuid4().hex}.jpg"
|
|
63
|
+
storage.save(key, out, content_type="image/jpeg")
|
|
64
|
+
except Exception:
|
|
65
|
+
raw.seek(0)
|
|
66
|
+
storage.save(key, raw)
|
|
67
|
+
|
|
68
|
+
return redirect(url_for("gallery"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.route("/gallery")
|
|
72
|
+
def gallery():
|
|
73
|
+
photos = [storage.photo_url(key) for key in storage.list_keys()]
|
|
74
|
+
return render_template("gallery.html", photos=photos)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
app.run(debug=True, port=5000)
|
snapshare/storage.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""S3-compatible object storage for photos.
|
|
2
|
+
|
|
3
|
+
Backed by Tigris in production. Fly.io's `fly storage create` injects
|
|
4
|
+
AWS_ENDPOINT_URL_S3, BUCKET_NAME, and credentials as app secrets, so no
|
|
5
|
+
explicit configuration is needed. Tests use moto.
|
|
6
|
+
|
|
7
|
+
The bucket is private (Tigris' default), so gallery photos are served via
|
|
8
|
+
short-lived presigned GET URLs generated per gallery view. See
|
|
9
|
+
`photo_url()`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import BinaryIO
|
|
14
|
+
|
|
15
|
+
import boto3
|
|
16
|
+
|
|
17
|
+
PRESIGNED_URL_TTL_SECONDS = 3600 # 1 hour
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client():
|
|
21
|
+
# Fresh client per call so moto's mock_aws intercepts in tests.
|
|
22
|
+
return boto3.client("s3")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _bucket() -> str:
|
|
26
|
+
return os.environ.get("BUCKET_NAME", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save(key: str, body: BinaryIO, content_type: str = "application/octet-stream") -> None:
|
|
30
|
+
_client().put_object(Bucket=_bucket(), Key=key, Body=body, ContentType=content_type)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_keys() -> list[str]:
|
|
34
|
+
resp = _client().list_objects_v2(Bucket=_bucket())
|
|
35
|
+
objects = resp.get("Contents", [])
|
|
36
|
+
objects.sort(key=lambda o: o["LastModified"], reverse=True)
|
|
37
|
+
return [o["Key"] for o in objects]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def photo_url(key: str) -> str:
|
|
41
|
+
return _client().generate_presigned_url(
|
|
42
|
+
"get_object",
|
|
43
|
+
Params={"Bucket": _bucket(), "Key": key},
|
|
44
|
+
ExpiresIn=PRESIGNED_URL_TTL_SECONDS,
|
|
45
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>SnapShare — Gallery</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
background: #f5f5f5;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
padding: 1.5rem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
header {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: baseline;
|
|
20
|
+
gap: 1rem;
|
|
21
|
+
margin-bottom: 1.5rem;
|
|
22
|
+
max-width: 960px;
|
|
23
|
+
margin-left: auto;
|
|
24
|
+
margin-right: auto;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
header h1 { font-size: 1.6rem; }
|
|
28
|
+
|
|
29
|
+
.upload-link {
|
|
30
|
+
margin-left: auto;
|
|
31
|
+
color: #6366f1;
|
|
32
|
+
text-decoration: none;
|
|
33
|
+
font-size: .9rem;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
}
|
|
36
|
+
.upload-link:hover { text-decoration: underline; }
|
|
37
|
+
|
|
38
|
+
.count { color: #888; font-size: .9rem; }
|
|
39
|
+
|
|
40
|
+
.grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
43
|
+
gap: .75rem;
|
|
44
|
+
max-width: 960px;
|
|
45
|
+
margin: 0 auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.grid a {
|
|
49
|
+
display: block;
|
|
50
|
+
border-radius: .6rem;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
background: #ddd;
|
|
53
|
+
aspect-ratio: 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.grid img {
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: 100%;
|
|
59
|
+
object-fit: cover;
|
|
60
|
+
display: block;
|
|
61
|
+
transition: transform .2s;
|
|
62
|
+
}
|
|
63
|
+
.grid a:hover img { transform: scale(1.04); }
|
|
64
|
+
|
|
65
|
+
.empty {
|
|
66
|
+
text-align: center;
|
|
67
|
+
color: #aaa;
|
|
68
|
+
margin-top: 4rem;
|
|
69
|
+
font-size: 1.1rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Lightbox */
|
|
73
|
+
#lightbox {
|
|
74
|
+
display: none;
|
|
75
|
+
position: fixed;
|
|
76
|
+
inset: 0;
|
|
77
|
+
background: rgba(0,0,0,.85);
|
|
78
|
+
align-items: center;
|
|
79
|
+
justify-content: center;
|
|
80
|
+
z-index: 100;
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
}
|
|
83
|
+
#lightbox.open { display: flex; }
|
|
84
|
+
#lightbox img {
|
|
85
|
+
max-width: 100%;
|
|
86
|
+
max-height: 90vh;
|
|
87
|
+
border-radius: .5rem;
|
|
88
|
+
box-shadow: 0 8px 40px rgba(0,0,0,.5);
|
|
89
|
+
}
|
|
90
|
+
#lightbox .close {
|
|
91
|
+
position: absolute;
|
|
92
|
+
top: 1rem; right: 1.25rem;
|
|
93
|
+
color: #fff;
|
|
94
|
+
font-size: 2rem;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
line-height: 1;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<header>
|
|
102
|
+
<h1>SnapShare</h1>
|
|
103
|
+
{% if photos %}
|
|
104
|
+
<span class="count">{{ photos|length }} photo{{ 's' if photos|length != 1 }}</span>
|
|
105
|
+
{% endif %}
|
|
106
|
+
<a href="/" class="upload-link">+ Add a photo</a>
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
{% if photos %}
|
|
110
|
+
<div class="grid">
|
|
111
|
+
{% for photo in photos %}
|
|
112
|
+
<a href="#" data-src="{{ photo }}" class="thumb">
|
|
113
|
+
<img src="{{ photo }}" alt="shared photo" loading="lazy">
|
|
114
|
+
</a>
|
|
115
|
+
{% endfor %}
|
|
116
|
+
</div>
|
|
117
|
+
{% else %}
|
|
118
|
+
<p class="empty">No photos yet — be the first to share one!</p>
|
|
119
|
+
{% endif %}
|
|
120
|
+
|
|
121
|
+
<div id="lightbox">
|
|
122
|
+
<span class="close" id="lbClose">×</span>
|
|
123
|
+
<img id="lbImg" src="" alt="full size photo">
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<script>
|
|
127
|
+
const lightbox = document.getElementById('lightbox');
|
|
128
|
+
const lbImg = document.getElementById('lbImg');
|
|
129
|
+
|
|
130
|
+
document.querySelectorAll('.thumb').forEach(a => {
|
|
131
|
+
a.addEventListener('click', e => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
lbImg.src = a.dataset.src;
|
|
134
|
+
lightbox.classList.add('open');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
document.getElementById('lbClose').addEventListener('click', close);
|
|
139
|
+
lightbox.addEventListener('click', e => { if (e.target === lightbox) close(); });
|
|
140
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
|
141
|
+
|
|
142
|
+
function close() { lightbox.classList.remove('open'); lbImg.src = ''; }
|
|
143
|
+
</script>
|
|
144
|
+
</body>
|
|
145
|
+
</html>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>SnapShare — Share Your Photos</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
background: #f5f5f5;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
padding: 1.5rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.card {
|
|
22
|
+
background: #fff;
|
|
23
|
+
border-radius: 1rem;
|
|
24
|
+
box-shadow: 0 4px 24px rgba(0,0,0,.08);
|
|
25
|
+
padding: 2.5rem 2rem;
|
|
26
|
+
width: 100%;
|
|
27
|
+
max-width: 420px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
h1 { font-size: 1.6rem; margin-bottom: .25rem; }
|
|
31
|
+
.subtitle { color: #666; font-size: .95rem; margin-bottom: 2rem; }
|
|
32
|
+
|
|
33
|
+
.drop-zone {
|
|
34
|
+
border: 2px dashed #ccc;
|
|
35
|
+
border-radius: .75rem;
|
|
36
|
+
padding: 2.5rem 1rem;
|
|
37
|
+
text-align: center;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
transition: border-color .2s, background .2s;
|
|
40
|
+
position: relative;
|
|
41
|
+
}
|
|
42
|
+
.drop-zone:hover, .drop-zone.dragover {
|
|
43
|
+
border-color: #6366f1;
|
|
44
|
+
background: #f0f0ff;
|
|
45
|
+
}
|
|
46
|
+
.drop-zone input[type="file"] {
|
|
47
|
+
position: absolute;
|
|
48
|
+
inset: 0;
|
|
49
|
+
opacity: 0;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 100%;
|
|
53
|
+
}
|
|
54
|
+
.drop-zone .icon { font-size: 2.5rem; display: block; margin-bottom: .5rem; }
|
|
55
|
+
.drop-zone .hint { color: #888; font-size: .85rem; margin-top: .25rem; }
|
|
56
|
+
|
|
57
|
+
#preview {
|
|
58
|
+
margin-top: 1rem;
|
|
59
|
+
display: none;
|
|
60
|
+
border-radius: .5rem;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
}
|
|
63
|
+
#preview img {
|
|
64
|
+
width: 100%;
|
|
65
|
+
max-height: 220px;
|
|
66
|
+
object-fit: cover;
|
|
67
|
+
display: block;
|
|
68
|
+
}
|
|
69
|
+
#preview .filename {
|
|
70
|
+
background: #f5f5f5;
|
|
71
|
+
padding: .4rem .75rem;
|
|
72
|
+
font-size: .8rem;
|
|
73
|
+
color: #555;
|
|
74
|
+
word-break: break-all;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.error {
|
|
78
|
+
margin-top: 1rem;
|
|
79
|
+
padding: .75rem 1rem;
|
|
80
|
+
background: #fff0f0;
|
|
81
|
+
border-left: 3px solid #e55;
|
|
82
|
+
color: #c00;
|
|
83
|
+
border-radius: .25rem;
|
|
84
|
+
font-size: .9rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
button[type="submit"] {
|
|
88
|
+
margin-top: 1.25rem;
|
|
89
|
+
width: 100%;
|
|
90
|
+
padding: .85rem;
|
|
91
|
+
background: #6366f1;
|
|
92
|
+
color: #fff;
|
|
93
|
+
border: none;
|
|
94
|
+
border-radius: .75rem;
|
|
95
|
+
font-size: 1rem;
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: background .2s;
|
|
99
|
+
}
|
|
100
|
+
button[type="submit"]:hover { background: #4f51d8; }
|
|
101
|
+
button[type="submit"]:disabled { background: #a5b4fc; cursor: not-allowed; }
|
|
102
|
+
|
|
103
|
+
.gallery-link {
|
|
104
|
+
display: block;
|
|
105
|
+
text-align: center;
|
|
106
|
+
margin-top: 1.25rem;
|
|
107
|
+
color: #6366f1;
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
font-size: .9rem;
|
|
110
|
+
}
|
|
111
|
+
.gallery-link:hover { text-decoration: underline; }
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div class="card">
|
|
116
|
+
<h1>SnapShare</h1>
|
|
117
|
+
<p class="subtitle">Upload your photos to the shared album.</p>
|
|
118
|
+
|
|
119
|
+
<form method="post" action="/upload" enctype="multipart/form-data" id="uploadForm">
|
|
120
|
+
<div class="drop-zone" id="dropZone">
|
|
121
|
+
<span class="icon">📷</span>
|
|
122
|
+
<strong>Tap to choose a photo</strong><br>
|
|
123
|
+
<span class="hint">or drag & drop here</span>
|
|
124
|
+
<input type="file" name="photo" accept="image/*" id="fileInput" required>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div id="preview">
|
|
128
|
+
<img id="previewImg" src="" alt="preview">
|
|
129
|
+
<div class="filename" id="previewName"></div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{% if error %}
|
|
133
|
+
<div class="error">{{ error }}</div>
|
|
134
|
+
{% endif %}
|
|
135
|
+
|
|
136
|
+
<button type="submit" id="submitBtn" disabled>Upload Photo</button>
|
|
137
|
+
</form>
|
|
138
|
+
|
|
139
|
+
<a href="/gallery" class="gallery-link">View shared gallery →</a>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<script>
|
|
143
|
+
const input = document.getElementById('fileInput');
|
|
144
|
+
const preview = document.getElementById('preview');
|
|
145
|
+
const previewImg = document.getElementById('previewImg');
|
|
146
|
+
const previewName = document.getElementById('previewName');
|
|
147
|
+
const submitBtn = document.getElementById('submitBtn');
|
|
148
|
+
const dropZone = document.getElementById('dropZone');
|
|
149
|
+
|
|
150
|
+
input.addEventListener('change', () => handleFile(input.files[0]));
|
|
151
|
+
|
|
152
|
+
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
153
|
+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
|
154
|
+
dropZone.addEventListener('drop', e => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
dropZone.classList.remove('dragover');
|
|
157
|
+
const file = e.dataTransfer.files[0];
|
|
158
|
+
if (file) { input.files = e.dataTransfer.files; handleFile(file); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function handleFile(file) {
|
|
162
|
+
if (!file) return;
|
|
163
|
+
const url = URL.createObjectURL(file);
|
|
164
|
+
previewImg.src = url;
|
|
165
|
+
previewName.textContent = file.name;
|
|
166
|
+
preview.style.display = 'block';
|
|
167
|
+
submitBtn.disabled = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
document.getElementById('uploadForm').addEventListener('submit', () => {
|
|
171
|
+
submitBtn.disabled = true;
|
|
172
|
+
submitBtn.textContent = 'Uploading…';
|
|
173
|
+
});
|
|
174
|
+
</script>
|
|
175
|
+
</body>
|
|
176
|
+
</html>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snapshare
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Lightweight photo-sharing web app for group events
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: boto3==1.43.89
|
|
8
|
+
Requires-Dist: flask==3.1.3
|
|
9
|
+
Requires-Dist: gunicorn==26.2.0
|
|
10
|
+
Requires-Dist: pillow==12.3.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: build==1.6.0; extra == "dev"
|
|
13
|
+
Requires-Dist: moto[s3]==5.2.3; extra == "dev"
|
|
14
|
+
Requires-Dist: pre-commit==4.6.2; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest==9.1.1; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff==0.16.6; extra == "dev"
|
|
17
|
+
Requires-Dist: twine==7.0.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# SnapShare
|
|
20
|
+
|
|
21
|
+
A lightweight photo-sharing web app for group events. Guests scan a link (or QR code), upload photos from their phone, and instantly see everyone else's shots in a shared gallery.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- Python 3.13+
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python3 -m venv .venv
|
|
31
|
+
source .venv/bin/activate
|
|
32
|
+
pip install '.[dev]' # installs app + dev deps from pyproject.toml
|
|
33
|
+
pre-commit install # wire up git hooks
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Running locally
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m snapshare.app
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Open [http://localhost:5000](http://localhost:5000).
|
|
43
|
+
|
|
44
|
+
Uploaded photos are saved to an `uploads/` directory next to the module (created automatically, git-ignored).
|
|
45
|
+
|
|
46
|
+
## Project structure
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
snapshare/
|
|
50
|
+
├── pyproject.toml # Project metadata, dependencies, tool config
|
|
51
|
+
├── src/
|
|
52
|
+
│ └── snapshare/
|
|
53
|
+
│ ├── __init__.py
|
|
54
|
+
│ ├── app.py # Flask app — routes and upload logic
|
|
55
|
+
│ └── templates/
|
|
56
|
+
│ ├── index.html # Upload form
|
|
57
|
+
│ └── gallery.html # Shared photo gallery
|
|
58
|
+
└── tests/ # Pytest suite
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Linting & formatting
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
ruff check . # lint
|
|
65
|
+
ruff check --fix . # lint + auto-fix
|
|
66
|
+
ruff format . # format
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Testing
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Publishing to PyPI
|
|
76
|
+
|
|
77
|
+
Version is derived from the nearest `v*` git tag via `setuptools-scm`. Building
|
|
78
|
+
from an untagged commit produces a dev version (e.g. `0.2.2.dev1+g1234abc`).
|
|
79
|
+
To cut a real release from a tagged commit:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git checkout v0.2.1 # or whichever tag
|
|
83
|
+
rm -rf dist/ build/ # clean any previous build output
|
|
84
|
+
python -m build # creates dist/snapshare-0.2.1-*.whl and .tar.gz
|
|
85
|
+
twine check dist/* # validate metadata + long description
|
|
86
|
+
twine upload dist/* # uploads to PyPI (needs API token, see below)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**PyPI credentials:** create an API token at
|
|
90
|
+
[pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
|
|
91
|
+
scoped to the `snapshare` project. `twine` reads it from `~/.pypirc` or the
|
|
92
|
+
`TWINE_USERNAME=__token__` + `TWINE_PASSWORD=pypi-...` env vars.
|
|
93
|
+
|
|
94
|
+
## Roadmap
|
|
95
|
+
|
|
96
|
+
- [ ] Event/album scoping with shareable links
|
|
97
|
+
- [ ] QR code generation for events
|
|
98
|
+
- [ ] Cloud storage backend (S3 / GCS)
|
|
99
|
+
- [ ] Mobile-optimised gallery
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
snapshare/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
snapshare/app.py,sha256=_XchyjQIIPWFB3U31Zv-Zn5LDFt5CWL1wi4WgzflP2w,2287
|
|
3
|
+
snapshare/storage.py,sha256=LMSJw3FcRNbmNF5LVjgdx6NrCjbFlxb3Thjhc-vMbeo,1289
|
|
4
|
+
snapshare/templates/gallery.html,sha256=_tcZHyelL0iZ8x5i3fUqQJX59Gs_QH0qU9QMsghPyPM,3511
|
|
5
|
+
snapshare/templates/index.html,sha256=GSwLIihl-9-32tB_Bxb8rLt4TtLTNPstKcGR-TL7Nas,5011
|
|
6
|
+
snapshare-0.2.2.dist-info/METADATA,sha256=uEINRQnHLXuhYnJSQJEhO4Hz-L-Q-NrbYKmX2P6NnX8,2921
|
|
7
|
+
snapshare-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
snapshare-0.2.2.dist-info/top_level.txt,sha256=RJlqKY32umbIGqgsIqmWjRqDvZLFJ9FVpaN0S7sFnL8,10
|
|
9
|
+
snapshare-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
snapshare
|