Initial commit

This commit is contained in:
snfsx 2025-04-19 12:17:00 +00:00
commit e1213c62a4
Signed by: snfsx
GPG key ID: B663DED13303E929
10 changed files with 997 additions and 0 deletions

37
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1 @@
todo: README, take listening port from .env

176
app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>