Initial commit
This commit is contained in:
commit
e1213c62a4
10 changed files with 997 additions and 0 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.env
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.ipynb_checkpoints
|
||||
instance/
|
||||
.webassets-cache
|
||||
.vscode/
|
||||
.DS_Store
|
||||
*.log
|
||||
venv/
|
9
LICENSE
Normal file
9
LICENSE
Normal file
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 snfsx.xyz
|
||||
|
||||
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.
|
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
todo: README, take listening port from .env
|
176
app.py
Normal file
176
app.py
Normal file
|
@ -0,0 +1,176 @@
|
|||
# app.py
|
||||
# Copyright (c) 2025 snfsx.xyz
|
||||
# Licensed under the MIT License. See https://opensource.org/licenses/MIT
|
||||
|
||||
import os
|
||||
import datetime
|
||||
import requests
|
||||
from flask import Flask, render_template, jsonify, request, send_from_directory
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
)
|
||||
|
||||
|
||||
limiter = Limiter(
|
||||
get_remote_address,
|
||||
app=app,
|
||||
default_limits=["300 per day", "50 per hour"],
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
|
||||
# --- Configuration from .env ---
|
||||
|
||||
lastfm_apik = os.getenv('LASTFM_API_KEY')
|
||||
lastfm_usrname = os.getenv('LASTFM_USERNAME')
|
||||
usrname = os.getenv('USER_NAME', 'User')
|
||||
age = os.getenv('USER_AGE', '')
|
||||
bio = os.getenv('USER_BIO', '#')
|
||||
tg = os.getenv('USER_TELEGRAM_TAG')
|
||||
bd = os.getenv('USER_BIRTHDAY', '')
|
||||
github_usrn = os.getenv('GITHUB_USERNAME')
|
||||
email = os.getenv('EMAIL_ADDRESS')
|
||||
jabber = os.getenv('JABBER_ADDRESS')
|
||||
ton = os.getenv('TON_ADDRESS')
|
||||
ton_v = os.getenv('TON_VERSION')
|
||||
favicon = os.getenv('FAVICON_TEXT')
|
||||
|
||||
# ---------------------------------
|
||||
|
||||
|
||||
# ----------- Functions --------------
|
||||
|
||||
def get_lastfm_now_playing():
|
||||
"""Fetches the currently playing track from Last.fm API."""
|
||||
if not lastfm_apik or not lastfm_usrname:
|
||||
return {'error': 'Last.fm API key or username not configured.'}
|
||||
|
||||
api_url = f"http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={lastfm_usrname}&api_key={lastfm_apik}&format=json&limit=1"
|
||||
|
||||
try:
|
||||
response = requests.get(api_url, timeout=10) # Added timeout
|
||||
response.raise_for_status() # Raise an exception for bad status codes
|
||||
data = response.json()
|
||||
|
||||
if 'recenttracks' in data and 'track' in data['recenttracks'] and data['recenttracks']['track']:
|
||||
track_data = data['recenttracks']['track'][0]
|
||||
is_now_playing = track_data.get('@attr', {}).get('nowplaying') == 'true'
|
||||
|
||||
if is_now_playing:
|
||||
artist = track_data['artist']['#text']
|
||||
name = track_data['name']
|
||||
# Get the largest available image URL
|
||||
image_url = next((img['#text'] for img in reversed(track_data.get('image', [])) if img['#text']), None)
|
||||
# Construct a plausible song.link URL (accuracy may vary)
|
||||
song_link = f"https://song.link/s/{requests.utils.quote(f'{name} {artist}')}"
|
||||
|
||||
|
||||
return {
|
||||
'playing': True,
|
||||
'artist': artist,
|
||||
'name': name,
|
||||
'image_url': image_url,
|
||||
'song_link': song_link
|
||||
}
|
||||
else:
|
||||
return {'playing': False, 'message': 'Not listening anything right now.'}
|
||||
else:
|
||||
# Handle cases where user has no tracks or invalid response structure
|
||||
return {'playing': False, 'message': 'No recent tracks found.'}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error fetching Last.fm data: {e}")
|
||||
return {'error': f'Could not connect to Last.fm API: {e}'}
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return {'error': 'An unexpected error occurred while fetching Last.fm data.'}
|
||||
|
||||
# ------- end functions ------------
|
||||
|
||||
|
||||
# ----------- Routes ----------------
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Renders the main page."""
|
||||
|
||||
personal_info = {
|
||||
'name': usrname,
|
||||
'age': age,
|
||||
'bio': bio,
|
||||
'telegram': tg,
|
||||
'birthday': bd,
|
||||
'github_username': github_usrn,
|
||||
'email': email,
|
||||
'jabber': jabber,
|
||||
'ton_version': ton_v,
|
||||
'ton_address': ton,
|
||||
'favicon': favicon,
|
||||
'nickname': usrname.lower(),
|
||||
'year': datetime.datetime.now().year
|
||||
}
|
||||
|
||||
host = request.host.split(':')[0]
|
||||
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
|
||||
if host == "snfsx.xyz" or host == "tests.snfsx.xyz":
|
||||
return render_template('index.html', info=personal_info)
|
||||
elif host == "ip.snfsx.xyz":
|
||||
return jsonify({"ip": ip})
|
||||
else:
|
||||
return jsonify({"error": "unknown host"})
|
||||
|
||||
@app.route('/api/v1/ip')
|
||||
def ip_return():
|
||||
|
||||
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
return jsonify({"ip": ip})
|
||||
|
||||
|
||||
@app.route('/gamelife')
|
||||
def gamelife_page():
|
||||
"""Just gamelife """
|
||||
return send_from_directory('static/html', 'gamelife.html')
|
||||
|
||||
@app.route('/api/v1/now_playing')
|
||||
@limiter.limit("1 per 5 second")
|
||||
def api_now_playing():
|
||||
"""API endpoint to get current Last.fm track."""
|
||||
track_info = get_lastfm_now_playing()
|
||||
return jsonify(track_info)
|
||||
|
||||
# -------------- End routes --------------
|
||||
|
||||
|
||||
# ------------ Error handlers -----------
|
||||
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_error(e):
|
||||
""" 429 handler """
|
||||
return jsonify(
|
||||
error="ratelimit_exceeded",
|
||||
message="Too many requests",
|
||||
), 429
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_not_found(error):
|
||||
"""404 handler"""
|
||||
app.logger.warning(f"404 Not Found: {request.path}")
|
||||
return render_template('404.html'), 404
|
||||
|
||||
# ------------ End error handlers -------------
|
||||
|
||||
|
||||
# Start
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
26
requirements.txt
Normal file
26
requirements.txt
Normal file
|
@ -0,0 +1,26 @@
|
|||
blinker==1.9.0
|
||||
certifi==2025.1.31
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
Deprecated==1.2.18
|
||||
dotenv==0.9.9
|
||||
Flask==3.1.0
|
||||
Flask-Limiter==3.12
|
||||
gunicorn==23.0.0
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
limits==4.7.3
|
||||
markdown-it-py==3.0.0
|
||||
MarkupSafe==3.0.2
|
||||
mdurl==0.1.2
|
||||
ordered-set==4.1.0
|
||||
packaging==24.2
|
||||
Pygments==2.19.1
|
||||
python-dotenv==1.1.0
|
||||
requests==2.32.3
|
||||
rich==13.9.4
|
||||
typing_extensions==4.13.2
|
||||
urllib3==2.4.0
|
||||
Werkzeug==3.1.3
|
||||
wrapt==1.17.2
|
21
run.py
Normal file
21
run.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
import sys
|
||||
import subprocess
|
||||
|
||||
port = 5000
|
||||
|
||||
def run_with_gunicorn():
|
||||
port = 5000
|
||||
cmd = [
|
||||
sys.executable, "-m", "gunicorn",
|
||||
"app:app",
|
||||
"--workers", "1",
|
||||
"--log-level", "error",
|
||||
"--bind", f"0.0.0.0:{port}"
|
||||
]
|
||||
|
||||
print(f"Starting Gunicorn on port {port}")
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_with_gunicorn()
|
323
static/css/style.css
Normal file
323
static/css/style.css
Normal file
|
@ -0,0 +1,323 @@
|
|||
/* style.css
|
||||
* Copyright (c) 2025 snfsx.xyz
|
||||
* Licensed under the MIT License. See https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
/* Basic Reset & Font */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Even Darker Theme Variables */
|
||||
--bg-color: #0b1414; /* Almost black desaturated cyan */
|
||||
--text-color: #a8c0bf; /* Further dimmed light grayish cyan */
|
||||
--primary-color: #48bfae; /* Keep relatively bright */
|
||||
--secondary-color: #4ca99d; /* Darkened secondary */
|
||||
--accent-color: #f5a57f; /* Contrasting accent - kept */
|
||||
--card-bg: #121f1f; /* Container background - KEEP THIS */
|
||||
--border-color: #1f3a3a; /* Darker border */
|
||||
--link-color: #50bbae; /* Adjusted link color */
|
||||
--link-hover-color: #68d4c6; /* Adjusted link hover color */
|
||||
--tag-bg: #1a423f; /* Darker tag background */
|
||||
--tag-text: var(--text-color); /* Match text color */
|
||||
--button-bg: var(--primary-color);
|
||||
--button-text: #0b1414; /* Match darkest background for text on button */
|
||||
--button-hover-bg: var(--secondary-color);
|
||||
--code-bg: #182e2e; /* Darker code background */
|
||||
/* --- CHANGE: Make section background same as container background --- */
|
||||
--section-card-bg: var(--card-bg); /* #121f1f */
|
||||
--music-card-bg: #101a1a; /* Slightly darker than section/container for music card */
|
||||
--section-shadow: rgba(0, 0, 0, 0.3); /* Slightly darker shadow for better visibility */
|
||||
--section-shadow-hover: rgba(0, 0, 0, 0.45); /* Shadow on hover */
|
||||
|
||||
|
||||
/* Transitions & Animations */
|
||||
--transition-speed: 0.3s;
|
||||
--entry-animation-duration: 0.8s; /* Duration for entry animation */
|
||||
}
|
||||
|
||||
/* --- Keyframes for Entry Animation --- */
|
||||
@keyframes slideDownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Body uses the new default dark theme */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 40px auto;
|
||||
padding: 20px; /* Padding around sections */
|
||||
/* --- KEEP THIS BACKGROUND --- */
|
||||
background-color: var(--card-bg); /* #121f1f */
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border-color);
|
||||
perspective: 1200px;
|
||||
|
||||
/* Apply Entry Animation */
|
||||
opacity: 0;
|
||||
animation: slideDownFadeIn var(--entry-animation-duration) ease-out forwards;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px; /* Space between header and first section */
|
||||
padding-bottom: 20px; /* Space within header */
|
||||
/* --- Make header blend with container, add border --- */
|
||||
background-color: transparent; /* Header blends with container */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
/* No padding like sections, let container padding handle spacing */
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.95em;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* --- Section as Card Styling & Animation --- */
|
||||
section {
|
||||
margin-bottom: 25px; /* Adjusted margin between section cards */
|
||||
padding: 25px; /* Padding inside each section card */
|
||||
/* --- CHANGE: Use darker background for section card --- */
|
||||
background-color: var(--section-card-bg); /* #121f1f */
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 10px var(--section-shadow);
|
||||
transition: transform var(--transition-speed) ease,
|
||||
box-shadow var(--transition-speed) ease;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
section:hover {
|
||||
transform: rotateY(3deg) scale(1.01);
|
||||
box-shadow: 0 8px 25px var(--section-shadow-hover);
|
||||
}
|
||||
|
||||
/* Remove bottom margin from the last section inside container */
|
||||
.container > section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* --- End Section Card Styling --- */
|
||||
|
||||
|
||||
h2 {
|
||||
color: var(--primary-color);
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px; /* More space below heading */
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--secondary-color);
|
||||
padding-bottom: 8px; /* Slightly more padding */
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
a1 {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--link-hover-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 10px; /* Increased spacing */
|
||||
}
|
||||
|
||||
li strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Now Playing Section */
|
||||
.music-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--music-card-bg); /* #101a1a */
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-top: 20px; /* More space above music card */
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 15px;
|
||||
background-color: var(--border-color);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
.album-cover.error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
.track-info {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#now-playing-status {
|
||||
font-style: italic;
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.9em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
#now-playing-status:not(:empty) + #now-playing-title {
|
||||
display: none;
|
||||
}
|
||||
#now-playing-status:not(:empty) + #now-playing-title + #now-playing-artist {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.track-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 2px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 0.95em;
|
||||
color: var(--secondary-color);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
/* Track title link */
|
||||
.track-title-link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-speed) ease;
|
||||
min-height: 1.2em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.track-title-link .track-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.track-title-link:not(.inactive-link):hover {
|
||||
color: var(--link-hover-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-title-link.inactive-link {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.track-title-link.inactive-link .track-title {
|
||||
color: var(--text-color);
|
||||
}
|
||||
.track-title-link:not(.inactive-link) .track-title {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
/* Donate Section */
|
||||
.donate-address-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#ton-address {
|
||||
background-color: var(--code-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
word-break: break-all;
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#copy-ton-button {
|
||||
padding: 10px 18px;
|
||||
background-color: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, transform 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#copy-ton-button:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
#copy-ton-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 40px; /* Space above footer */
|
||||
padding-top: 20px; /* Space within footer */
|
||||
padding-bottom: 10px;
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.85em;
|
||||
color: var(--secondary-color);
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
288
static/js/script.js
Normal file
288
static/js/script.js
Normal file
|
@ -0,0 +1,288 @@
|
|||
// script.js
|
||||
// Copyright (c) 2025 snfsx.xyz
|
||||
// Licensed under the MIT License. See https://opensource.org/licenses/MIT
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
// Прокручиваем в самый верх
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// --- Copy TON Address ---
|
||||
const copyTonButton = document.getElementById('copy-ton-button');
|
||||
const tonAddressElement = document.getElementById('ton-address');
|
||||
|
||||
// --- Helper Function for execCommand Fallback ---
|
||||
function copyUsingExecCommand(textToCopy) {
|
||||
let success = false;
|
||||
// Create a temporary textarea element
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = textToCopy;
|
||||
|
||||
// Make it non-editable to avoid focus issues
|
||||
textArea.setAttribute('readonly', '');
|
||||
|
||||
// ** IMPORTANT ** Style to make it invisible but selectable
|
||||
// Needs to be in the DOM and selectable to work
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px'; // Move off-screen
|
||||
textArea.style.top = '0';
|
||||
textArea.style.opacity = '0'; // Hide visually
|
||||
textArea.style.pointerEvents = 'none'; // Prevent interaction
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
|
||||
// Store original selection and focus
|
||||
const currentFocus = document.activeElement;
|
||||
const selectedRange = document.getSelection().rangeCount > 0
|
||||
? document.getSelection().getRangeAt(0)
|
||||
: false;
|
||||
|
||||
try {
|
||||
// Select text inside the textarea
|
||||
textArea.select();
|
||||
// textArea.setSelectionRange(0, 99999); // For mobile potentially
|
||||
|
||||
// Attempt to copy
|
||||
success = document.execCommand('copy');
|
||||
|
||||
if (!success) {
|
||||
console.warn('document.execCommand("copy") returned false.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error executing document.execCommand("copy"):', err);
|
||||
success = false;
|
||||
} finally {
|
||||
// Cleanup: remove the temporary element
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
// Restore original selection and focus
|
||||
if (selectedRange) {
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges(); // Clear selection from temp textarea
|
||||
selection.addRange(selectedRange); // Restore original selection
|
||||
}
|
||||
if (currentFocus && typeof currentFocus.focus === 'function') {
|
||||
// Only focus if it's focusable
|
||||
currentFocus.focus(); // Restore original focus
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
// --- End Helper Function ---
|
||||
|
||||
|
||||
if (copyTonButton && tonAddressElement) {
|
||||
copyTonButton.addEventListener('click', async () => { // Use async for await
|
||||
const address = (copyTonButton.dataset.address || tonAddressElement.textContent || '').trim();
|
||||
|
||||
if (!address) {
|
||||
console.error('Address to copy is empty.');
|
||||
alert('Cannot copy: Address is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
let copied = false;
|
||||
let copyMethod = ''; // To track which method worked
|
||||
|
||||
// --- METHOD 1: Try Modern Clipboard API (Requires HTTPS or localhost) ---
|
||||
// Check if API exists AND if we are in a secure context
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
copied = true;
|
||||
copyMethod = 'Clipboard API';
|
||||
} catch (err) {
|
||||
console.warn('navigator.clipboard.writeText failed (maybe permissions?):', err);
|
||||
// If it failed, we'll try the fallback method below
|
||||
}
|
||||
} else {
|
||||
console.warn('navigator.clipboard not available or context is not secure (HTTPS/localhost required). Trying fallback.');
|
||||
}
|
||||
|
||||
// --- METHOD 2: Fallback to execCommand (Legacy) ---
|
||||
if (!copied) {
|
||||
copied = copyUsingExecCommand(address);
|
||||
if (copied) {
|
||||
copyMethod = 'execCommand';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provide Feedback ---
|
||||
if (copied) {
|
||||
console.log(`Address copied successfully using: ${copyMethod}`);
|
||||
const originalText = copyTonButton.textContent;
|
||||
copyTonButton.textContent = 'Copied!';
|
||||
copyTonButton.disabled = true; // Disable button temporarily
|
||||
setTimeout(() => {
|
||||
copyTonButton.textContent = originalText;
|
||||
copyTonButton.disabled = false;
|
||||
}, 1500);
|
||||
} else {
|
||||
// If both methods failed
|
||||
console.error('Failed to copy address using both Clipboard API and execCommand.');
|
||||
alert('Failed to copy address. Please copy manually.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!copyTonButton) console.warn("Element with ID 'copy-ton-button' not found.");
|
||||
if (!tonAddressElement) console.warn("Element with ID 'ton-address' not found.");
|
||||
}
|
||||
// --- End Copy TON Address ---
|
||||
|
||||
|
||||
// --- Last.fm Now Playing ---
|
||||
const nowPlayingCover = document.getElementById('now-playing-cover');
|
||||
const nowPlayingStatus = document.getElementById('now-playing-status');
|
||||
const nowPlayingTitleLink = document.getElementById('now-playing-title-link');
|
||||
const nowPlayingTitleText = document.getElementById('now-playing-title');
|
||||
const nowPlayingArtist = document.getElementById('now-playing-artist');
|
||||
|
||||
const placeholderSvg = "data:image/svg+xml,%3Csvg width='80' height='80' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 80'%3E%3Crect width='100%25' height='100%25' fill='%231f3a3a'/%3E%3C/svg%3E";
|
||||
|
||||
let previousTrackId = null;
|
||||
let previousStatus = null; // can be 'playing', 'not_playing', 'error', 'rate_limited'
|
||||
|
||||
if (!nowPlayingCover || !nowPlayingStatus || !nowPlayingTitleLink || !nowPlayingTitleText || !nowPlayingArtist) {
|
||||
console.error("One or more Last.fm elements are missing from the DOM.");
|
||||
// Stop execution if elements are missing
|
||||
// Consider throwing an error or returning, depending on context
|
||||
// For this example, we'll just return to prevent further errors.
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to set the UI to an error/inactive state
|
||||
function setErrorState(message, statusIdentifier = 'error') {
|
||||
if (previousStatus !== statusIdentifier) {
|
||||
nowPlayingStatus.textContent = message;
|
||||
nowPlayingTitleText.textContent = '';
|
||||
nowPlayingArtist.textContent = '';
|
||||
if (nowPlayingCover.src !== placeholderSvg) {
|
||||
nowPlayingCover.src = placeholderSvg;
|
||||
}
|
||||
nowPlayingCover.alt = 'Error or inactive state';
|
||||
nowPlayingCover.classList.add('error'); // Add error class for visual feedback
|
||||
|
||||
nowPlayingTitleLink.removeAttribute('href');
|
||||
nowPlayingTitleLink.classList.add('inactive-link');
|
||||
nowPlayingTitleLink.setAttribute('aria-disabled', 'true');
|
||||
|
||||
previousStatus = statusIdentifier;
|
||||
previousTrackId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function updateNowPlaying() {
|
||||
fetch('/api/v1/now_playing')
|
||||
.then(response => {
|
||||
// --- START 429 Handling ---
|
||||
if (response.status === 429) {
|
||||
// Attempt to parse the JSON body for the custom message
|
||||
return response.json().then(errorData => {
|
||||
// Use the message from the server if available
|
||||
const message = errorData?.message || "Too many requests. Please wait.";
|
||||
setErrorState(message, 'rate_limited');
|
||||
// Throw an error to skip the rest of the .then chain and go to .catch
|
||||
// This signals that we handled this specific error type already
|
||||
throw new Error('Rate limit exceeded');
|
||||
}).catch(jsonError => {
|
||||
// If parsing the 429 JSON fails, use a default message
|
||||
console.error("Could not parse 429 response body:", jsonError);
|
||||
setErrorState("Too many requests. Please wait.", 'rate_limited');
|
||||
throw new Error('Rate limit exceeded'); // Still go to .catch
|
||||
});
|
||||
}
|
||||
// --- END 429 Handling ---
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle other HTTP errors (like 500, 404, etc.)
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
// If response is OK (2xx), parse the JSON
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// This block only runs if the response was OK (2xx) and not 429
|
||||
let currentStatus;
|
||||
let currentTrackId = null;
|
||||
|
||||
nowPlayingCover.classList.remove('error'); // Remove error class if previous state was error
|
||||
|
||||
// We already know data is not null/undefined if we reached here
|
||||
if (data.error) { // Handle application-level errors returned in JSON
|
||||
console.error('API Logic Error:', data.error);
|
||||
currentStatus = 'error';
|
||||
setErrorState(data.error || 'Error fetching data.'); // Use helper
|
||||
} else if (data.playing && data.artist && data.name) {
|
||||
currentStatus = 'playing';
|
||||
currentTrackId = `${data.artist}-${data.name}`;
|
||||
} else if (data.playing) {
|
||||
console.warn('API returned playing:true, but missing artist or name.');
|
||||
currentStatus = 'error';
|
||||
setErrorState('Incomplete track data received.'); // Use helper
|
||||
} else {
|
||||
currentStatus = 'not_playing';
|
||||
}
|
||||
|
||||
// Update UI only if state changed or track changed
|
||||
if (currentStatus !== previousStatus || (currentStatus === 'playing' && currentTrackId !== previousTrackId)) {
|
||||
|
||||
if (currentStatus === 'playing') {
|
||||
nowPlayingStatus.textContent = ''; // Clear status text
|
||||
nowPlayingTitleText.textContent = data.name;
|
||||
nowPlayingArtist.textContent = `by ${data.artist}`;
|
||||
|
||||
const lastfmUrl = `https://www.last.fm/music/${encodeURIComponent(data.artist)}/_/${encodeURIComponent(data.name)}`;
|
||||
nowPlayingTitleLink.href = lastfmUrl;
|
||||
nowPlayingTitleLink.classList.remove('inactive-link');
|
||||
nowPlayingTitleLink.removeAttribute('aria-disabled');
|
||||
|
||||
const newCoverSrc = data.image_url || placeholderSvg;
|
||||
if (nowPlayingCover.src !== newCoverSrc) {
|
||||
nowPlayingCover.src = newCoverSrc;
|
||||
}
|
||||
nowPlayingCover.alt = data.image_url ? `Album cover for ${data.name} by ${data.artist}` : 'Album cover not available';
|
||||
|
||||
} else if (currentStatus === 'not_playing') {
|
||||
nowPlayingStatus.textContent = data.message || 'Not listening right now.';
|
||||
nowPlayingTitleText.textContent = '';
|
||||
nowPlayingArtist.textContent = '';
|
||||
if (nowPlayingCover.src !== placeholderSvg) {
|
||||
nowPlayingCover.src = placeholderSvg;
|
||||
}
|
||||
nowPlayingCover.alt = 'Nothing playing';
|
||||
|
||||
nowPlayingTitleLink.removeAttribute('href');
|
||||
nowPlayingTitleLink.classList.add('inactive-link');
|
||||
nowPlayingTitleLink.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
// Error states are handled above or in .catch using setErrorState
|
||||
|
||||
previousStatus = currentStatus; // Update previous status only on successful processing
|
||||
previousTrackId = currentTrackId;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Catch network errors, HTTP errors (thrown from !response.ok),
|
||||
// and the specific 'Rate limit exceeded' error we threw.
|
||||
console.error('Fetch Error:', error);
|
||||
|
||||
// Avoid setting generic error if it was the rate limit we already handled
|
||||
if (error.message !== 'Rate limit exceeded') {
|
||||
// Use the helper function for generic network/HTTP errors
|
||||
setErrorState('Could not connect or error occurred.');
|
||||
}
|
||||
// If it *was* 'Rate limit exceeded', the UI is already set by the 429 handler.
|
||||
});
|
||||
}
|
||||
|
||||
updateNowPlaying();
|
||||
setInterval(updateNowPlaying, 30000);
|
||||
// --- End Last.fm Now Playing ---
|
||||
|
||||
});
|
25
templates/404.html
Normal file
25
templates/404.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<!-- 404.html -->
|
||||
<!-- Copyright (c) 2025 snfsx.xyz -->
|
||||
<!-- Licensed under the MIT License. See https://opensource.org/licenses/MIT -->
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">
|
||||
<title>404 - Page Not Found</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Link to Google Fonts for 'Inter' -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>404</h1>
|
||||
<p>The page you are looking for could not be found.</p>
|
||||
<a href="{{ url_for('index') }}">Return to Homepage</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
91
templates/index.html
Normal file
91
templates/index.html
Normal file
|
@ -0,0 +1,91 @@
|
|||
<!-- index.html -->
|
||||
<!-- Copyright (c) 2025 snfsx.xyz -->
|
||||
<!-- Licensed under the MIT License. See https://opensource.org/licenses/MIT -->
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">
|
||||
<title>{{ info.name }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>{{ info.favicon }}</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>{{ info.name }}</h1>
|
||||
<p class="subtitle">{{ info.age }} y.o. | {{ info.birthday }}</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="about">
|
||||
<h2>About</h2>
|
||||
<p><a1>{{ info.bio }}</a1></p>
|
||||
{% if info.tags %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
<section id="now-playing">
|
||||
<h2>Now Listening</h2>
|
||||
<div class="music-card">
|
||||
<img id="now-playing-cover"
|
||||
src="data:image/svg+xml,<svg viewBox='0 0 50 50' xmlns='http://www.w3.org/2000/svg'><circle cx='25' cy='25' r='10' fill='none' stroke='%2360d9c8' stroke-width='3' stroke-linecap='round' stroke-dasharray='31.4 62.8'><animateTransform attributeName='transform' type='rotate' from='0 25 25' to='360 25 25' dur='0.2s' repeatCount='indefinite'/></circle></svg>"
|
||||
alt="Loading album cover..."
|
||||
class="album-cover">
|
||||
<div class="track-info">
|
||||
<p id="now-playing-status"></p> <!-- Initial state empty -->
|
||||
<a id="now-playing-title-link" href="#" target="_blank" rel="noopener noreferrer" class="track-title-link inactive-link">
|
||||
<span id="now-playing-title" class="track-title"></span> <!-- Keep span for text content -->
|
||||
</a>
|
||||
<p id="now-playing-artist" class="track-artist"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="accounts">
|
||||
<h2>Links</h2>
|
||||
<ul>
|
||||
{% if info.github_username %}
|
||||
<li>Github: <a href="https://github.com/{{ info.github_username }}" target="_blank" rel="noopener noreferrer"><i>@{{ info.github_username }}</i></a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if info.telegram %}
|
||||
<li>Telegram: <a href="https://t.me/{{ info.telegram }}" target="_blank" rel="noopener noreferrer"><i>@{{ info.telegram }}</i></a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if info.email %}
|
||||
<li>Email: <a href="mailto:{{ info.email }}"><i>{{ info.email}}</i></a></li>
|
||||
{% endif %}
|
||||
{% if info.jabber %}
|
||||
<li>Jabber: <a href="xmpp:{{ info.jabber }}"><i>{{ info.jabber }}</i></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{% if info.ton_address %}
|
||||
<section id="donate">
|
||||
<h2>Donate</h2>
|
||||
<p>Support me via TON v{{ info.ton_version}}:</p>
|
||||
<div class="donate-address-container">
|
||||
<code id="ton-address">{{ info.ton_address }}</code>
|
||||
<button id="copy-ton-button" data-address="{{ info.ton_address }}">Copy</button>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© {{ info.nickname }} {{ info.year }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
Loading…
Add table
Reference in a new issue