Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- Dockerfile.txt +24 -0
- app.py +77 -0
- requirements.txt +3 -0
- templates/index.html +224 -0
Dockerfile.txt
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lightweight base image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# System setup
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1 \
|
| 7 |
+
PIP_NO_CACHE_DIR=1
|
| 8 |
+
|
| 9 |
+
# Create app dir
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Install Python deps early for better layer caching
|
| 13 |
+
COPY requirements.txt /app/
|
| 14 |
+
RUN pip install -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy app source
|
| 17 |
+
COPY . /app
|
| 18 |
+
|
| 19 |
+
# Expose Flask port (matches app.py: 7860)
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# Run with Gunicorn in production
|
| 23 |
+
# -w: workers (tweak for your CPU); -k sync is fine here
|
| 24 |
+
CMD ["gunicorn", "-w", "2", "-k", "gthread", "-t", "120", "-b", "0.0.0.0:7860", "app:app"]
|
app.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
import shutil
|
| 4 |
+
from flask import Flask, request, jsonify, render_template
|
| 5 |
+
from gradio_client import Client
|
| 6 |
+
|
| 7 |
+
app = Flask(__name__)
|
| 8 |
+
app.secret_key = os.urandom(24)
|
| 9 |
+
|
| 10 |
+
# Where we’ll drop generated mp3s for the browser to fetch
|
| 11 |
+
os.makedirs("static", exist_ok=True)
|
| 12 |
+
|
| 13 |
+
# Point to your HF Space (leave as-is if this is already working for you)
|
| 14 |
+
TTS_SPACE_URL = "https://altafo-free-tts-unlimted-words.hf.space/"
|
| 15 |
+
client = Client(TTS_SPACE_URL)
|
| 16 |
+
|
| 17 |
+
@app.route("/")
|
| 18 |
+
def index():
|
| 19 |
+
# expects templates/index.html
|
| 20 |
+
return render_template("index.html")
|
| 21 |
+
|
| 22 |
+
@app.route("/api/ping")
|
| 23 |
+
def ping():
|
| 24 |
+
return "pong", 200
|
| 25 |
+
|
| 26 |
+
@app.route("/tts", methods=["POST"])
|
| 27 |
+
def tts():
|
| 28 |
+
"""
|
| 29 |
+
JSON body:
|
| 30 |
+
{ "text": "...", "voice": "en-US-AriaNeural - en-US (Female)", "rate": 0, "pitch": 0 }
|
| 31 |
+
Returns:
|
| 32 |
+
{ "url": "/static/<uuid>.mp3" }
|
| 33 |
+
"""
|
| 34 |
+
data = request.get_json(force=True) or {}
|
| 35 |
+
text_input = (data.get("text") or "").strip()
|
| 36 |
+
if not text_input:
|
| 37 |
+
return jsonify({"error": "No text provided"}), 400
|
| 38 |
+
|
| 39 |
+
voice = (data.get("voice") or "en-US-AriaNeural - en-US (Female)").strip()
|
| 40 |
+
try:
|
| 41 |
+
rate = int(data.get("rate", 0))
|
| 42 |
+
except Exception:
|
| 43 |
+
rate = 0
|
| 44 |
+
try:
|
| 45 |
+
pitch = int(data.get("pitch", 0))
|
| 46 |
+
except Exception:
|
| 47 |
+
pitch = 0
|
| 48 |
+
|
| 49 |
+
# Clamp to safe ranges
|
| 50 |
+
rate = max(-50, min(50, rate))
|
| 51 |
+
pitch = max(-20, min(20, pitch))
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
# Call the Space
|
| 55 |
+
result = client.predict(
|
| 56 |
+
text_input,
|
| 57 |
+
voice,
|
| 58 |
+
rate,
|
| 59 |
+
pitch,
|
| 60 |
+
api_name="/tts_interface"
|
| 61 |
+
)
|
| 62 |
+
temp_path = result[0]
|
| 63 |
+
if not temp_path or not os.path.exists(temp_path):
|
| 64 |
+
return jsonify({"error": "TTS failed"}), 500
|
| 65 |
+
|
| 66 |
+
out_name = f"{uuid.uuid4().hex}.mp3"
|
| 67 |
+
out_path = os.path.join("static", out_name)
|
| 68 |
+
shutil.copy(temp_path, out_path)
|
| 69 |
+
|
| 70 |
+
return jsonify({"url": f"/static/{out_name}"})
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return jsonify({"error": f"TTS request failed: {e}"}), 500
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
print("🔊 TTS Flask server running on http://0.0.0.0:7860")
|
| 77 |
+
app.run(host="0.0.0.0", port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.0.3
|
| 2 |
+
gunicorn==21.2.0
|
| 3 |
+
gradio_client==1.3.0
|
templates/index.html
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<title>Anchor Dashboard — Text → Audio</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0b1220;--bg-soft:#10192e;--card:#121b31;--card-2:#0f1730;
|
| 10 |
+
--text:#e6edf7;--muted:#9fb0cf;--accent:#3a8dde;--accent-2:#6ea8ff;
|
| 11 |
+
--ring:rgba(58,141,222,.35);--radius:16px;--shadow:0 10px 30px rgba(2,8,23,.35);
|
| 12 |
+
}
|
| 13 |
+
*{box-sizing:border-box}
|
| 14 |
+
html,body{height:100%}
|
| 15 |
+
body{
|
| 16 |
+
margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Apple Color Emoji","Segoe UI Emoji";
|
| 17 |
+
color:var(--text);
|
| 18 |
+
background: radial-gradient(1200px 700px at 10% -10%, #1a2d55 0%, transparent 60%),
|
| 19 |
+
radial-gradient(1200px 800px at 110% 10%, #1b164e 0%, transparent 50%),
|
| 20 |
+
var(--bg);
|
| 21 |
+
padding:0 0 48px;
|
| 22 |
+
}
|
| 23 |
+
.app-header{position:sticky;top:0;z-index:5;backdrop-filter:blur(10px);
|
| 24 |
+
background:linear-gradient(180deg, rgba(16,25,46,.9), rgba(16,25,46,.6));
|
| 25 |
+
border-bottom:1px solid rgba(255,255,255,.06);padding:16px 24px;}
|
| 26 |
+
.app-header h1{margin:0;font-size:18px;font-weight:600;letter-spacing:.3px;color:#dfe9ff}
|
| 27 |
+
.container{max-width:1000px;margin:24px auto 0;padding:0 20px;display:grid;gap:16px}
|
| 28 |
+
.card{background:linear-gradient(180deg,var(--card),var(--card-2));
|
| 29 |
+
border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);box-shadow:var(--shadow)}
|
| 30 |
+
.panel{padding:16px}
|
| 31 |
+
.section-title{padding:14px 16px;margin:0;border-bottom:1px solid rgba(255,255,255,.06);
|
| 32 |
+
color:#dce7ff;font-weight:600;letter-spacing:.2px}
|
| 33 |
+
button,.btn{
|
| 34 |
+
appearance:none;border:1px solid rgba(255,255,255,.08);
|
| 35 |
+
background:linear-gradient(180deg,#1b2645,#16213d);color:var(--text);
|
| 36 |
+
padding:10px 16px;border-radius:12px;cursor:pointer;
|
| 37 |
+
transition:transform .08s ease, box-shadow .2s ease, border-color .2s ease;
|
| 38 |
+
box-shadow:0 4px 12px rgba(0,0,0,.25);font-weight:600
|
| 39 |
+
}
|
| 40 |
+
button:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 8px 20px rgba(58,141,222,.25)}
|
| 41 |
+
label{color:var(--muted);font-size:14px}
|
| 42 |
+
select,input[type="file"],textarea{
|
| 43 |
+
background:#0f1730;color:var(--text);border:1px solid rgba(255,255,255,.08);
|
| 44 |
+
border-radius:12px;padding:10px 12px
|
| 45 |
+
}
|
| 46 |
+
select:focus,input[type="file"]:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--ring)}
|
| 47 |
+
textarea{width:100%;min-height:220px;resize:vertical}
|
| 48 |
+
.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
|
| 49 |
+
.dropzone{border:2px dashed rgba(255,255,255,.15);border-radius:14px;padding:18px;text-align:center;color:var(--muted);
|
| 50 |
+
background:linear-gradient(180deg,#101a35,#0e1530);cursor:pointer}
|
| 51 |
+
.dropzone.is-dragover{background:linear-gradient(180deg,#0f2a55,#142a5a);border-color:var(--accent);color:#e2ecff}
|
| 52 |
+
.helper{font-size:.9em;color:#9fb0cf;margin:8px 0 0}
|
| 53 |
+
#apiStatus{color:#9fb0cf;margin:10px 0}
|
| 54 |
+
#downloadLink{display:none;margin-left:12px;text-decoration:none;color:white}
|
| 55 |
+
#downloadLink:hover{text-decoration:underline}
|
| 56 |
+
</style>
|
| 57 |
+
</head>
|
| 58 |
+
<body>
|
| 59 |
+
<header class="app-header">
|
| 60 |
+
<h1>Anchor Dashboard — Text to Speech</h1>
|
| 61 |
+
</header>
|
| 62 |
+
|
| 63 |
+
<div class="container">
|
| 64 |
+
<section class="card panel">
|
| 65 |
+
<h3 class="section-title">Your Text</h3>
|
| 66 |
+
|
| 67 |
+
<div class="row" style="justify-content:space-between">
|
| 68 |
+
<div style="flex:1 1 100%">
|
| 69 |
+
<textarea id="textInput" placeholder="Paste or type text here…"></textarea>
|
| 70 |
+
<p class="helper">Tip: you can also drop a <b>.txt</b> file below and we’ll load it here.</p>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div id="dropZone" class="dropzone" style="margin-top:12px">
|
| 75 |
+
Drop a .txt file here or click to choose
|
| 76 |
+
<div style="margin-top:6px;font-size:.95em;color:#e2ecff;display:none" id="dzFile"></div>
|
| 77 |
+
</div>
|
| 78 |
+
<input id="txtFile" type="file" accept=".txt,text/plain" style="display:none" />
|
| 79 |
+
|
| 80 |
+
<h3 class="section-title" style="margin-top:16px">AI Playback</h3>
|
| 81 |
+
<div class="row" style="margin-top:8px">
|
| 82 |
+
<label>
|
| 83 |
+
Voice:
|
| 84 |
+
<select id="voiceSelect">
|
| 85 |
+
<option>en-US-AvaNeural - en-US (Female)</option>
|
| 86 |
+
<option>en-US-AndrewNeural - en-US (Male)</option>
|
| 87 |
+
<option>en-US-EmmaNeural - en-US (Female)</option>
|
| 88 |
+
<option>en-US-BrianNeural - en-US (Male)</option>
|
| 89 |
+
<option>en-US-EmmaMultilingualNeural - en-US (Female)</option>
|
| 90 |
+
<option>en-US-EricNeural - en-US (Male)</option>
|
| 91 |
+
<option>en-US-GuyNeural - en-US (Male)</option>
|
| 92 |
+
<option>en-US-JennyNeural - en-US (Female)</option>
|
| 93 |
+
<option>en-US-MichelleNeural - en-US (Female)</option>
|
| 94 |
+
<option>en-US-RogerNeural - en-US (Male)</option>
|
| 95 |
+
<option>en-US-SteffanNeural - en-US (Male)</option>
|
| 96 |
+
<option>en-US-AnaNeural - en-US (Female)</option>
|
| 97 |
+
<option>en-US-AndrewMultilingualNeural - en-US (Male)</option>
|
| 98 |
+
<option>en-US-AriaNeural - en-US (Female)</option>
|
| 99 |
+
<option>en-US-AvaMultilingualNeural - en-US (Female)</option>
|
| 100 |
+
<option>en-US-BrianMultilingualNeural - en-US (Male)</option>
|
| 101 |
+
<option>en-US-ChristopherNeural - en-US (Male)</option>
|
| 102 |
+
</select>
|
| 103 |
+
</label>
|
| 104 |
+
|
| 105 |
+
<label>
|
| 106 |
+
Speech Rate (%):
|
| 107 |
+
<select id="rateSelect"></select>
|
| 108 |
+
</label>
|
| 109 |
+
|
| 110 |
+
<label>
|
| 111 |
+
Pitch (Hz):
|
| 112 |
+
<select id="pitchSelect"></select>
|
| 113 |
+
</label>
|
| 114 |
+
|
| 115 |
+
<button id="generateBtn">Generate Audio</button>
|
| 116 |
+
<a id="downloadLink" href="#" download="tts.mp3">Download audio</a>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div id="apiStatus"></div>
|
| 120 |
+
<div id="audioPlayerContainer"></div>
|
| 121 |
+
</section>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<script>
|
| 125 |
+
/* --------- rate & pitch options ---------- */
|
| 126 |
+
(function initRatePitch(){
|
| 127 |
+
const rateSel = document.getElementById("rateSelect");
|
| 128 |
+
const pitchSel = document.getElementById("pitchSelect");
|
| 129 |
+
for (let v = -50; v <= 50; v += 1){
|
| 130 |
+
const opt = document.createElement("option");
|
| 131 |
+
opt.value = String(v);
|
| 132 |
+
opt.textContent = v > 0 ? `+${v}` : String(v);
|
| 133 |
+
if (v === 0) opt.selected = true;
|
| 134 |
+
rateSel.appendChild(opt);
|
| 135 |
+
}
|
| 136 |
+
for (let v = -20; v <= 20; v += 1){
|
| 137 |
+
const opt = document.createElement("option");
|
| 138 |
+
opt.value = String(v);
|
| 139 |
+
opt.textContent = v > 0 ? `+${v}` : String(v);
|
| 140 |
+
if (v === 0) opt.selected = true;
|
| 141 |
+
pitchSel.appendChild(opt);
|
| 142 |
+
}
|
| 143 |
+
})();
|
| 144 |
+
|
| 145 |
+
/* --------- TTS trigger ---------- */
|
| 146 |
+
async function runTTS(){
|
| 147 |
+
const textEl = document.getElementById("textInput");
|
| 148 |
+
const status = document.getElementById("apiStatus");
|
| 149 |
+
const player = document.getElementById("audioPlayerContainer");
|
| 150 |
+
const dl = document.getElementById("downloadLink");
|
| 151 |
+
|
| 152 |
+
const text = (textEl.value || "").trim();
|
| 153 |
+
const voice = document.getElementById("voiceSelect").value;
|
| 154 |
+
const rate = parseInt(document.getElementById("rateSelect").value || "0", 10);
|
| 155 |
+
const pitch = parseInt(document.getElementById("pitchSelect").value || "0", 10);
|
| 156 |
+
|
| 157 |
+
if (!text){
|
| 158 |
+
alert("Please enter or load some text first.");
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
status.textContent = "Generating audio…";
|
| 163 |
+
player.innerHTML = "";
|
| 164 |
+
dl.style.display = "none";
|
| 165 |
+
|
| 166 |
+
try{
|
| 167 |
+
const res = await fetch("/tts", {
|
| 168 |
+
method:"POST",
|
| 169 |
+
headers: {"Content-Type":"application/json"},
|
| 170 |
+
body: JSON.stringify({ text, voice, rate, pitch })
|
| 171 |
+
});
|
| 172 |
+
const data = await res.json();
|
| 173 |
+
if (!res.ok){
|
| 174 |
+
throw new Error(data?.error || "TTS failed");
|
| 175 |
+
}
|
| 176 |
+
const url = data.url;
|
| 177 |
+
player.innerHTML = `<audio controls style="width:100%"><source src="${url}" type="audio/mpeg"></audio>`;
|
| 178 |
+
dl.href = url;
|
| 179 |
+
dl.style.display = "inline-block";
|
| 180 |
+
status.textContent = "Done.";
|
| 181 |
+
}catch(err){
|
| 182 |
+
console.error(err);
|
| 183 |
+
status.textContent = "";
|
| 184 |
+
alert("TTS failed: " + err.message);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
document.getElementById("generateBtn").addEventListener("click", runTTS);
|
| 188 |
+
|
| 189 |
+
/* --------- Dropzone for .txt ---------- */
|
| 190 |
+
const dz = document.getElementById("dropZone");
|
| 191 |
+
const dzFile = document.getElementById("dzFile");
|
| 192 |
+
const fileInput = document.getElementById("txtFile");
|
| 193 |
+
|
| 194 |
+
["dragenter","dragover","dragleave","drop"].forEach(evt =>
|
| 195 |
+
document.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); })
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
["dragenter","dragover"].forEach(evt =>
|
| 199 |
+
dz.addEventListener(evt, () => dz.classList.add("is-dragover"))
|
| 200 |
+
);
|
| 201 |
+
["dragleave","drop"].forEach(evt =>
|
| 202 |
+
dz.addEventListener(evt, () => dz.classList.remove("is-dragover"))
|
| 203 |
+
);
|
| 204 |
+
|
| 205 |
+
dz.addEventListener("click", () => fileInput.click());
|
| 206 |
+
|
| 207 |
+
function loadTxtFile(file){
|
| 208 |
+
if (!file) return;
|
| 209 |
+
const ok = file.type === "text/plain" || file.name.toLowerCase().endsWith(".txt");
|
| 210 |
+
if (!ok){ alert("Please choose a .txt file."); return; }
|
| 211 |
+
const reader = new FileReader();
|
| 212 |
+
reader.onload = () => {
|
| 213 |
+
document.getElementById("textInput").value = reader.result || "";
|
| 214 |
+
dzFile.style.display = "block";
|
| 215 |
+
dzFile.textContent = file.name + " — loaded.";
|
| 216 |
+
};
|
| 217 |
+
reader.readAsText(file);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
dz.addEventListener("drop", e => loadTxtFile(e.dataTransfer?.files?.[0]));
|
| 221 |
+
fileInput.addEventListener("change", e => loadTxtFile(e.target.files?.[0]));
|
| 222 |
+
</script>
|
| 223 |
+
</body>
|
| 224 |
+
</html>
|