sharedserver 0.1.0__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.
- sharedserver/__init__.py +4 -0
- sharedserver/__main__.py +6 -0
- sharedserver/cli.py +76 -0
- sharedserver/clipboard.py +32 -0
- sharedserver/config.py +151 -0
- sharedserver/file_manager.py +131 -0
- sharedserver/models.py +41 -0
- sharedserver/security.py +51 -0
- sharedserver/server.py +142 -0
- sharedserver/static/app.css +226 -0
- sharedserver/static/app.js +358 -0
- sharedserver/templates/index.html +89 -0
- sharedserver/websocket.py +57 -0
- sharedserver-0.1.0.dist-info/METADATA +177 -0
- sharedserver-0.1.0.dist-info/RECORD +19 -0
- sharedserver-0.1.0.dist-info/WHEEL +5 -0
- sharedserver-0.1.0.dist-info/entry_points.txt +2 -0
- sharedserver-0.1.0.dist-info/licenses/LICENSE +22 -0
- sharedserver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--bg: #f6f8fa;
|
|
4
|
+
--panel: #ffffff;
|
|
5
|
+
--panel-subtle: #f6f8fa;
|
|
6
|
+
--text: #1f2328;
|
|
7
|
+
--muted: #636c76;
|
|
8
|
+
--border: #d0d7de;
|
|
9
|
+
--accent: #0969da;
|
|
10
|
+
--accent-hover: #0757b7;
|
|
11
|
+
--success: #1a7f37;
|
|
12
|
+
--danger: #cf222e;
|
|
13
|
+
--shadow: 0 1px 2px rgba(31, 35, 40, .06), 0 8px 24px rgba(140, 149, 159, .12);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0d1117;
|
|
19
|
+
--panel: #161b22;
|
|
20
|
+
--panel-subtle: #0d1117;
|
|
21
|
+
--text: #e6edf3;
|
|
22
|
+
--muted: #8d96a0;
|
|
23
|
+
--border: #30363d;
|
|
24
|
+
--accent: #2f81f7;
|
|
25
|
+
--accent-hover: #58a6ff;
|
|
26
|
+
--success: #3fb950;
|
|
27
|
+
--danger: #f85149;
|
|
28
|
+
--shadow: 0 0 0 1px rgba(240, 246, 252, .04), 0 16px 32px rgba(0, 0, 0, .24);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
min-width: 320px;
|
|
37
|
+
background: var(--bg);
|
|
38
|
+
color: var(--text);
|
|
39
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
button, input, textarea { font: inherit; }
|
|
43
|
+
|
|
44
|
+
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible {
|
|
45
|
+
outline: 2px solid var(--accent);
|
|
46
|
+
outline-offset: 2px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.topbar {
|
|
50
|
+
position: sticky;
|
|
51
|
+
z-index: 10;
|
|
52
|
+
top: 0;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
min-height: 64px;
|
|
57
|
+
padding: 0 max(20px, calc((100vw - 1120px) / 2));
|
|
58
|
+
border-bottom: 1px solid var(--border);
|
|
59
|
+
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
|
60
|
+
backdrop-filter: blur(12px);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.brand { display: flex; align-items: center; gap: 11px; }
|
|
64
|
+
.brand div { display: flex; flex-direction: column; line-height: 1.2; }
|
|
65
|
+
.brand strong { font-size: 15px; letter-spacing: -.01em; }
|
|
66
|
+
.brand div span { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
|
67
|
+
|
|
68
|
+
.logo {
|
|
69
|
+
display: grid;
|
|
70
|
+
width: 34px;
|
|
71
|
+
height: 34px;
|
|
72
|
+
place-items: center;
|
|
73
|
+
border-radius: 9px;
|
|
74
|
+
background: var(--text);
|
|
75
|
+
color: var(--panel);
|
|
76
|
+
font-size: 16px;
|
|
77
|
+
font-weight: 750;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.connection {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 7px;
|
|
84
|
+
color: var(--muted);
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #bf8700; }
|
|
89
|
+
.connection.online .status-dot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 16%, transparent); }
|
|
90
|
+
.connection.offline .status-dot { background: var(--danger); }
|
|
91
|
+
|
|
92
|
+
.page { width: min(1120px, calc(100% - 32px)); margin: 32px auto 64px; }
|
|
93
|
+
|
|
94
|
+
.panel {
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
margin-bottom: 24px;
|
|
97
|
+
padding: 24px;
|
|
98
|
+
border: 1px solid var(--border);
|
|
99
|
+
border-radius: 12px;
|
|
100
|
+
background: var(--panel);
|
|
101
|
+
box-shadow: var(--shadow);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
|
|
105
|
+
.section-heading h1, .section-heading h2 { margin: 0; font-size: 21px; line-height: 1.3; letter-spacing: -.02em; }
|
|
106
|
+
.section-heading p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); }
|
|
107
|
+
.eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
|
108
|
+
|
|
109
|
+
.button {
|
|
110
|
+
min-height: 34px;
|
|
111
|
+
padding: 5px 13px;
|
|
112
|
+
border: 1px solid var(--border);
|
|
113
|
+
border-radius: 7px;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.button.primary { border-color: var(--accent); background: var(--accent); color: white; }
|
|
120
|
+
.button.primary:hover { background: var(--accent-hover); }
|
|
121
|
+
.button.secondary { background: var(--panel-subtle); color: var(--text); }
|
|
122
|
+
.button.secondary:hover { border-color: color-mix(in srgb, var(--border) 60%, var(--text)); }
|
|
123
|
+
.button.small { min-height: 29px; padding: 3px 9px; font-size: 12px; }
|
|
124
|
+
|
|
125
|
+
#shared-text {
|
|
126
|
+
display: block;
|
|
127
|
+
width: 100%;
|
|
128
|
+
min-height: 190px;
|
|
129
|
+
resize: vertical;
|
|
130
|
+
padding: 14px 16px;
|
|
131
|
+
border: 1px solid var(--border);
|
|
132
|
+
border-radius: 8px;
|
|
133
|
+
background: var(--panel-subtle);
|
|
134
|
+
color: var(--text);
|
|
135
|
+
font: 14px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#shared-text:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
139
|
+
#shared-text:disabled { cursor: wait; opacity: .65; }
|
|
140
|
+
.textarea-meta { display: flex; justify-content: space-between; margin-top: 8px; color: var(--muted); font-size: 11px; }
|
|
141
|
+
|
|
142
|
+
.files-heading { align-items: center; }
|
|
143
|
+
.toolbar { display: flex; gap: 8px; }
|
|
144
|
+
|
|
145
|
+
.breadcrumbs { display: flex; align-items: center; min-height: 36px; margin: 0 -8px 12px; overflow-x: auto; white-space: nowrap; }
|
|
146
|
+
.breadcrumbs button { padding: 4px 8px; border: 0; background: none; color: var(--accent); cursor: pointer; font-weight: 600; }
|
|
147
|
+
.breadcrumbs button:last-child { color: var(--text); cursor: default; }
|
|
148
|
+
.breadcrumbs span { color: var(--muted); }
|
|
149
|
+
|
|
150
|
+
.drop-zone {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
min-height: 52px;
|
|
156
|
+
margin-bottom: 14px;
|
|
157
|
+
border: 1px dashed var(--border);
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
background: var(--panel-subtle);
|
|
160
|
+
color: var(--muted);
|
|
161
|
+
transition: border-color .15s, background .15s;
|
|
162
|
+
}
|
|
163
|
+
.drop-zone small::before { content: "· "; }
|
|
164
|
+
.drop-zone.dragging { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--panel)); color: var(--accent); }
|
|
165
|
+
|
|
166
|
+
.upload-list:empty { display: none; }
|
|
167
|
+
.upload-list { display: grid; gap: 8px; margin-bottom: 14px; }
|
|
168
|
+
.upload-item { display: grid; grid-template-columns: minmax(100px, 1fr) 160px 50px; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
|
|
169
|
+
.upload-item .upload-name { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
|
|
170
|
+
.upload-item progress { width: 100%; accent-color: var(--accent); }
|
|
171
|
+
.upload-item.error { color: var(--danger); }
|
|
172
|
+
|
|
173
|
+
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
|
|
174
|
+
table { width: 100%; border-collapse: collapse; }
|
|
175
|
+
th { padding: 9px 12px; background: var(--panel-subtle); color: var(--muted); font-size: 11px; font-weight: 600; text-align: left; text-transform: uppercase; }
|
|
176
|
+
th:last-child { text-align: right; }
|
|
177
|
+
td { padding: 10px 12px; border-top: 1px solid var(--border); vertical-align: middle; }
|
|
178
|
+
tbody tr:hover { background: color-mix(in srgb, var(--panel-subtle) 55%, transparent); }
|
|
179
|
+
.name-cell { min-width: 220px; font-weight: 600; }
|
|
180
|
+
.name-content { display: flex; align-items: center; min-width: 0; gap: 9px; }
|
|
181
|
+
.file-icon { flex: 0 0 18px; width: 18px; color: var(--muted); text-align: center; }
|
|
182
|
+
.entry-name, .folder-link { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
183
|
+
.folder-link { padding: 0; border: 0; background: none; color: var(--accent); cursor: pointer; font: inherit; font-weight: 600; text-align: left; }
|
|
184
|
+
.size-cell, .date-cell { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
185
|
+
.actions { min-width: 160px; }
|
|
186
|
+
.action-list { display: flex; justify-content: flex-end; gap: 6px; white-space: nowrap; }
|
|
187
|
+
.download-link { display: inline-grid; place-items: center; text-decoration: none; }
|
|
188
|
+
.empty-state { padding: 48px 20px; color: var(--muted); text-align: center; }
|
|
189
|
+
|
|
190
|
+
dialog { width: min(420px, calc(100% - 32px)); padding: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); color: var(--text); box-shadow: 0 24px 64px rgba(0, 0, 0, .35); }
|
|
191
|
+
dialog::backdrop { background: rgba(0, 0, 0, .48); backdrop-filter: blur(2px); }
|
|
192
|
+
dialog form { padding: 22px; }
|
|
193
|
+
dialog h2 { margin: 0 0 18px; font-size: 19px; }
|
|
194
|
+
dialog label { display: block; margin-bottom: 6px; font-weight: 600; }
|
|
195
|
+
dialog input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel-subtle); color: var(--text); }
|
|
196
|
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
197
|
+
|
|
198
|
+
.toast { position: fixed; z-index: 30; right: 20px; bottom: 20px; max-width: min(380px, calc(100% - 40px)); padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--text); color: var(--panel); box-shadow: var(--shadow); opacity: 0; transform: translateY(12px); pointer-events: none; transition: .2s; }
|
|
199
|
+
.toast.visible { opacity: 1; transform: translateY(0); }
|
|
200
|
+
.toast.error { background: var(--danger); color: white; }
|
|
201
|
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
202
|
+
|
|
203
|
+
@media (max-width: 700px) {
|
|
204
|
+
.topbar { min-height: 56px; padding: 0 16px; }
|
|
205
|
+
.page { width: min(100% - 20px, 1120px); margin-top: 16px; }
|
|
206
|
+
.panel { padding: 16px; border-radius: 10px; }
|
|
207
|
+
.section-heading { flex-direction: column; }
|
|
208
|
+
.files-heading { align-items: stretch; }
|
|
209
|
+
.toolbar { width: 100%; }
|
|
210
|
+
.toolbar .button { flex: 1; }
|
|
211
|
+
.clipboard-panel .section-heading .button { align-self: flex-start; }
|
|
212
|
+
.drop-zone { flex-direction: column; gap: 0; padding: 10px; }
|
|
213
|
+
.drop-zone small::before { content: ""; }
|
|
214
|
+
table { table-layout: fixed; }
|
|
215
|
+
.size-cell, .date-cell, th:nth-child(2), th:nth-child(3) { display: none; }
|
|
216
|
+
th:last-child { width: 138px; }
|
|
217
|
+
.name-cell, .actions { min-width: 0; }
|
|
218
|
+
.action-list { gap: 4px; }
|
|
219
|
+
.upload-item { grid-template-columns: minmax(80px, 1fr) 100px 38px; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@media (max-width: 480px) {
|
|
223
|
+
.brand div span { display: none; }
|
|
224
|
+
th, td { padding: 9px 8px; }
|
|
225
|
+
.button.small { padding-inline: 7px; }
|
|
226
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const elements = {
|
|
4
|
+
connection: document.querySelector("#connection"),
|
|
5
|
+
text: document.querySelector("#shared-text"),
|
|
6
|
+
count: document.querySelector("#character-count"),
|
|
7
|
+
copyShared: document.querySelector("#copy-shared"),
|
|
8
|
+
breadcrumbs: document.querySelector("#breadcrumbs"),
|
|
9
|
+
fileList: document.querySelector("#file-list"),
|
|
10
|
+
empty: document.querySelector("#empty-state"),
|
|
11
|
+
chooseFiles: document.querySelector("#choose-files"),
|
|
12
|
+
fileInput: document.querySelector("#file-input"),
|
|
13
|
+
dropZone: document.querySelector("#drop-zone"),
|
|
14
|
+
uploadList: document.querySelector("#upload-list"),
|
|
15
|
+
newFolder: document.querySelector("#new-folder"),
|
|
16
|
+
folderDialog: document.querySelector("#folder-dialog"),
|
|
17
|
+
folderForm: document.querySelector("#folder-form"),
|
|
18
|
+
folderName: document.querySelector("#folder-name"),
|
|
19
|
+
cancelFolder: document.querySelector("#cancel-folder"),
|
|
20
|
+
toast: document.querySelector("#toast"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let currentPath = "";
|
|
24
|
+
let socket;
|
|
25
|
+
let reconnectDelay = 500;
|
|
26
|
+
let toastTimer;
|
|
27
|
+
|
|
28
|
+
function showToast(message, isError = false) {
|
|
29
|
+
clearTimeout(toastTimer);
|
|
30
|
+
elements.toast.textContent = message;
|
|
31
|
+
elements.toast.className = `toast visible${isError ? " error" : ""}`;
|
|
32
|
+
toastTimer = setTimeout(() => { elements.toast.className = "toast"; }, 3200);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function api(url, options) {
|
|
36
|
+
const response = await fetch(url, options);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
let message = `Request failed (${response.status})`;
|
|
39
|
+
try { message = (await response.json()).detail || message; } catch (_) { /* no JSON body */ }
|
|
40
|
+
throw new Error(message);
|
|
41
|
+
}
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setConnection(state, label) {
|
|
46
|
+
elements.connection.className = `connection ${state}`;
|
|
47
|
+
elements.connection.lastElementChild.textContent = label;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function connectClipboard() {
|
|
51
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
52
|
+
socket = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
53
|
+
setConnection("pending", "Connecting");
|
|
54
|
+
|
|
55
|
+
socket.addEventListener("open", () => {
|
|
56
|
+
reconnectDelay = 500;
|
|
57
|
+
setConnection("online", "Live sync connected");
|
|
58
|
+
});
|
|
59
|
+
socket.addEventListener("message", event => {
|
|
60
|
+
let message;
|
|
61
|
+
try { message = JSON.parse(event.data); } catch (_) { return; }
|
|
62
|
+
if (message.type === "clipboard") {
|
|
63
|
+
elements.text.disabled = false;
|
|
64
|
+
if (elements.text.value !== message.content) elements.text.value = message.content;
|
|
65
|
+
updateCount();
|
|
66
|
+
} else if (message.type === "error") {
|
|
67
|
+
showToast(message.message, true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
socket.addEventListener("close", () => {
|
|
71
|
+
elements.text.disabled = true;
|
|
72
|
+
setConnection("offline", "Connection lost, retrying");
|
|
73
|
+
setTimeout(connectClipboard, reconnectDelay);
|
|
74
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 8000);
|
|
75
|
+
});
|
|
76
|
+
socket.addEventListener("error", () => socket.close());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateCount() {
|
|
80
|
+
const count = elements.text.value.length;
|
|
81
|
+
elements.count.textContent = `${count.toLocaleString()} ${count === 1 ? "character" : "characters"}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
elements.text.addEventListener("input", () => {
|
|
85
|
+
updateCount();
|
|
86
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
87
|
+
socket.send(JSON.stringify({type: "clipboard", content: elements.text.value}));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function copyText(text) {
|
|
92
|
+
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
|
93
|
+
await navigator.clipboard.writeText(text);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const helper = document.createElement("textarea");
|
|
97
|
+
helper.value = text;
|
|
98
|
+
helper.style.position = "fixed";
|
|
99
|
+
helper.style.opacity = "0";
|
|
100
|
+
document.body.append(helper);
|
|
101
|
+
helper.select();
|
|
102
|
+
const copied = document.execCommand("copy");
|
|
103
|
+
helper.remove();
|
|
104
|
+
if (!copied) throw new Error("Clipboard access was denied by the browser");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
elements.copyShared.addEventListener("click", async () => {
|
|
108
|
+
try {
|
|
109
|
+
await copyText(elements.text.value);
|
|
110
|
+
showToast("Text copied");
|
|
111
|
+
} catch (error) { showToast(error.message, true); }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function pathParams(path) {
|
|
115
|
+
const params = new URLSearchParams();
|
|
116
|
+
params.set("path", path);
|
|
117
|
+
return params;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderBreadcrumbs(path) {
|
|
121
|
+
elements.breadcrumbs.replaceChildren();
|
|
122
|
+
const parts = path ? path.split("/") : [];
|
|
123
|
+
const crumbs = [{name: "Shared folder", path: ""}];
|
|
124
|
+
let built = "";
|
|
125
|
+
for (const part of parts) {
|
|
126
|
+
built = built ? `${built}/${part}` : part;
|
|
127
|
+
crumbs.push({name: part, path: built});
|
|
128
|
+
}
|
|
129
|
+
crumbs.forEach((crumb, index) => {
|
|
130
|
+
if (index) {
|
|
131
|
+
const separator = document.createElement("span");
|
|
132
|
+
separator.textContent = "/";
|
|
133
|
+
elements.breadcrumbs.append(separator);
|
|
134
|
+
}
|
|
135
|
+
const button = document.createElement("button");
|
|
136
|
+
button.type = "button";
|
|
137
|
+
button.textContent = crumb.name;
|
|
138
|
+
if (index < crumbs.length - 1) button.addEventListener("click", () => loadDirectory(crumb.path));
|
|
139
|
+
elements.breadcrumbs.append(button);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatSize(bytes) {
|
|
144
|
+
if (bytes === null) return "—";
|
|
145
|
+
if (bytes === 0) return "0 B";
|
|
146
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
147
|
+
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
148
|
+
return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function button(label, action, accessibleLabel = label) {
|
|
152
|
+
const node = document.createElement("button");
|
|
153
|
+
node.type = "button";
|
|
154
|
+
node.className = "button secondary small";
|
|
155
|
+
node.textContent = label;
|
|
156
|
+
node.setAttribute("aria-label", accessibleLabel);
|
|
157
|
+
node.title = accessibleLabel;
|
|
158
|
+
node.addEventListener("click", action);
|
|
159
|
+
return node;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderEntry(entry) {
|
|
163
|
+
const row = document.createElement("tr");
|
|
164
|
+
const nameCell = document.createElement("td");
|
|
165
|
+
nameCell.className = "name-cell";
|
|
166
|
+
const nameContent = document.createElement("div");
|
|
167
|
+
nameContent.className = "name-content";
|
|
168
|
+
const icon = document.createElement("span");
|
|
169
|
+
icon.className = "file-icon";
|
|
170
|
+
icon.textContent = entry.is_directory ? "▰" : "▤";
|
|
171
|
+
nameContent.append(icon);
|
|
172
|
+
|
|
173
|
+
if (entry.is_directory) {
|
|
174
|
+
const link = document.createElement("button");
|
|
175
|
+
link.type = "button";
|
|
176
|
+
link.className = "folder-link";
|
|
177
|
+
link.textContent = entry.name;
|
|
178
|
+
link.title = entry.name;
|
|
179
|
+
link.addEventListener("click", () => loadDirectory(entry.path));
|
|
180
|
+
nameContent.append(link);
|
|
181
|
+
} else {
|
|
182
|
+
const name = document.createElement("span");
|
|
183
|
+
name.className = "entry-name";
|
|
184
|
+
name.textContent = entry.name;
|
|
185
|
+
name.title = entry.name;
|
|
186
|
+
nameContent.append(name);
|
|
187
|
+
}
|
|
188
|
+
nameCell.append(nameContent);
|
|
189
|
+
|
|
190
|
+
const sizeCell = document.createElement("td");
|
|
191
|
+
sizeCell.className = "size-cell";
|
|
192
|
+
sizeCell.textContent = formatSize(entry.size);
|
|
193
|
+
const dateCell = document.createElement("td");
|
|
194
|
+
dateCell.className = "date-cell";
|
|
195
|
+
dateCell.textContent = new Date(entry.modified).toLocaleString();
|
|
196
|
+
const actions = document.createElement("td");
|
|
197
|
+
actions.className = "actions";
|
|
198
|
+
const actionList = document.createElement("div");
|
|
199
|
+
actionList.className = "action-list";
|
|
200
|
+
|
|
201
|
+
if (!entry.is_directory) {
|
|
202
|
+
if (entry.copy_kind === "text") {
|
|
203
|
+
actionList.append(button("Copy", () => copyFileText(entry.path), "Copy file text"));
|
|
204
|
+
} else if (entry.copy_kind === "image") {
|
|
205
|
+
actionList.append(button("Copy", () => copyImage(entry.path), "Copy image"));
|
|
206
|
+
}
|
|
207
|
+
const download = document.createElement("a");
|
|
208
|
+
download.className = "button secondary small download-link";
|
|
209
|
+
download.textContent = "Download";
|
|
210
|
+
download.setAttribute("aria-label", `Download ${entry.name}`);
|
|
211
|
+
download.title = `Download ${entry.name}`;
|
|
212
|
+
download.href = `/api/download?${pathParams(entry.path)}`;
|
|
213
|
+
actionList.append(download);
|
|
214
|
+
}
|
|
215
|
+
actions.append(actionList);
|
|
216
|
+
row.append(nameCell, sizeCell, dateCell, actions);
|
|
217
|
+
return row;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function loadDirectory(path = currentPath) {
|
|
221
|
+
try {
|
|
222
|
+
const response = await api(`/api/files?${pathParams(path)}`);
|
|
223
|
+
const listing = await response.json();
|
|
224
|
+
currentPath = listing.path;
|
|
225
|
+
renderBreadcrumbs(currentPath);
|
|
226
|
+
elements.fileList.replaceChildren(...listing.entries.map(renderEntry));
|
|
227
|
+
elements.empty.hidden = listing.entries.length !== 0;
|
|
228
|
+
} catch (error) { showToast(error.message, true); }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function copyFileText(path) {
|
|
232
|
+
try {
|
|
233
|
+
const response = await api(`/api/text?${pathParams(path)}`);
|
|
234
|
+
await copyText((await response.json()).content);
|
|
235
|
+
showToast("File contents copied");
|
|
236
|
+
} catch (error) { showToast(error.message, true); }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function blobToPng(blob) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const image = new Image();
|
|
242
|
+
const url = URL.createObjectURL(blob);
|
|
243
|
+
image.onload = () => {
|
|
244
|
+
const canvas = document.createElement("canvas");
|
|
245
|
+
canvas.width = image.naturalWidth;
|
|
246
|
+
canvas.height = image.naturalHeight;
|
|
247
|
+
canvas.getContext("2d").drawImage(image, 0, 0);
|
|
248
|
+
canvas.toBlob(result => {
|
|
249
|
+
URL.revokeObjectURL(url);
|
|
250
|
+
result ? resolve(result) : reject(new Error("Image conversion failed"));
|
|
251
|
+
}, "image/png");
|
|
252
|
+
};
|
|
253
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Could not read the image")); };
|
|
254
|
+
image.src = url;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function copyImage(path) {
|
|
259
|
+
if (!window.ClipboardItem || !navigator.clipboard?.write) {
|
|
260
|
+
showToast("Image copying is unavailable here. Download the image instead.", true);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const pngPromise = api(`/api/image?${pathParams(path)}`)
|
|
265
|
+
.then(response => response.blob())
|
|
266
|
+
.then(blobToPng);
|
|
267
|
+
await navigator.clipboard.write([new ClipboardItem({"image/png": pngPromise})]);
|
|
268
|
+
showToast("Image copied");
|
|
269
|
+
} catch (error) { showToast(`Copy failed: ${error.message}`, true); }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function uploadFile(file) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const item = document.createElement("div");
|
|
275
|
+
item.className = "upload-item";
|
|
276
|
+
const name = document.createElement("span");
|
|
277
|
+
name.className = "upload-name";
|
|
278
|
+
name.textContent = file.name;
|
|
279
|
+
const progress = document.createElement("progress");
|
|
280
|
+
progress.max = 100;
|
|
281
|
+
progress.value = 0;
|
|
282
|
+
const percent = document.createElement("span");
|
|
283
|
+
percent.textContent = "0%";
|
|
284
|
+
item.append(name, progress, percent);
|
|
285
|
+
elements.uploadList.append(item);
|
|
286
|
+
|
|
287
|
+
const params = pathParams(currentPath);
|
|
288
|
+
params.set("filename", file.name);
|
|
289
|
+
const request = new XMLHttpRequest();
|
|
290
|
+
request.open("PUT", `/api/upload?${params}`);
|
|
291
|
+
request.setRequestHeader("Content-Type", "application/octet-stream");
|
|
292
|
+
request.upload.addEventListener("progress", event => {
|
|
293
|
+
if (!event.lengthComputable) return;
|
|
294
|
+
const value = Math.round((event.loaded / event.total) * 100);
|
|
295
|
+
progress.value = value;
|
|
296
|
+
percent.textContent = `${value}%`;
|
|
297
|
+
});
|
|
298
|
+
request.addEventListener("load", () => {
|
|
299
|
+
if (request.status >= 200 && request.status < 300) {
|
|
300
|
+
progress.value = 100;
|
|
301
|
+
percent.textContent = "Done";
|
|
302
|
+
setTimeout(() => item.remove(), 1500);
|
|
303
|
+
resolve();
|
|
304
|
+
} else {
|
|
305
|
+
let message = `Upload failed (${request.status})`;
|
|
306
|
+
try { message = JSON.parse(request.responseText).detail || message; } catch (_) { /* no JSON body */ }
|
|
307
|
+
item.classList.add("error");
|
|
308
|
+
percent.textContent = "Failed";
|
|
309
|
+
reject(new Error(`${file.name}: ${message}`));
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
request.addEventListener("error", () => reject(new Error(`${file.name}: network error`)));
|
|
313
|
+
request.send(file);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function uploadFiles(files) {
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
try { await uploadFile(file); } catch (error) { showToast(error.message, true); }
|
|
320
|
+
}
|
|
321
|
+
elements.fileInput.value = "";
|
|
322
|
+
await loadDirectory();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
elements.chooseFiles.addEventListener("click", () => elements.fileInput.click());
|
|
326
|
+
elements.fileInput.addEventListener("change", () => uploadFiles([...elements.fileInput.files]));
|
|
327
|
+
["dragenter", "dragover"].forEach(type => elements.dropZone.addEventListener(type, event => {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
elements.dropZone.classList.add("dragging");
|
|
330
|
+
}));
|
|
331
|
+
["dragleave", "drop"].forEach(type => elements.dropZone.addEventListener(type, event => {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
elements.dropZone.classList.remove("dragging");
|
|
334
|
+
}));
|
|
335
|
+
elements.dropZone.addEventListener("drop", event => uploadFiles([...event.dataTransfer.files]));
|
|
336
|
+
|
|
337
|
+
elements.newFolder.addEventListener("click", () => {
|
|
338
|
+
elements.folderForm.reset();
|
|
339
|
+
elements.folderDialog.showModal();
|
|
340
|
+
elements.folderName.focus();
|
|
341
|
+
});
|
|
342
|
+
elements.cancelFolder.addEventListener("click", () => elements.folderDialog.close());
|
|
343
|
+
elements.folderForm.addEventListener("submit", async event => {
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
try {
|
|
346
|
+
await api("/api/directories", {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: {"Content-Type": "application/json"},
|
|
349
|
+
body: JSON.stringify({path: currentPath, name: elements.folderName.value}),
|
|
350
|
+
});
|
|
351
|
+
elements.folderDialog.close();
|
|
352
|
+
showToast("Folder created");
|
|
353
|
+
await loadDirectory();
|
|
354
|
+
} catch (error) { showToast(error.message, true); }
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
connectClipboard();
|
|
358
|
+
loadDirectory();
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<meta name="theme-color" content="#0d1117">
|
|
8
|
+
<title>SharedServer</title>
|
|
9
|
+
<link rel="stylesheet" href="/static/app.css">
|
|
10
|
+
<script src="/static/app.js" defer></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="topbar">
|
|
14
|
+
<div class="brand">
|
|
15
|
+
<span class="logo" aria-hidden="true">S</span>
|
|
16
|
+
<div>
|
|
17
|
+
<strong>SharedServer</strong>
|
|
18
|
+
<span>Local network sharing</span>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div id="connection" class="connection pending" role="status">
|
|
22
|
+
<span class="status-dot"></span>
|
|
23
|
+
<span>Connecting</span>
|
|
24
|
+
</div>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<main class="page">
|
|
28
|
+
<section class="panel clipboard-panel" aria-labelledby="clipboard-title">
|
|
29
|
+
<div class="section-heading">
|
|
30
|
+
<div>
|
|
31
|
+
<p class="eyebrow">Live sync</p>
|
|
32
|
+
<h1 id="clipboard-title">Shared text clipboard</h1>
|
|
33
|
+
<p>Type on any device and every connected device updates instantly.</p>
|
|
34
|
+
</div>
|
|
35
|
+
<button id="copy-shared" class="button secondary" type="button">Copy text</button>
|
|
36
|
+
</div>
|
|
37
|
+
<label class="sr-only" for="shared-text">Shared text</label>
|
|
38
|
+
<textarea id="shared-text" rows="8" maxlength="1000000" placeholder="Paste or type text here…" disabled></textarea>
|
|
39
|
+
<div class="textarea-meta"><span id="character-count">0 characters</span><span>Stored in memory only</span></div>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="panel files-panel" aria-labelledby="files-title">
|
|
43
|
+
<div class="section-heading files-heading">
|
|
44
|
+
<div>
|
|
45
|
+
<p class="eyebrow">Shared directory</p>
|
|
46
|
+
<h2 id="files-title">File browser</h2>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="toolbar">
|
|
49
|
+
<button id="new-folder" class="button secondary" type="button">New folder</button>
|
|
50
|
+
<button id="choose-files" class="button primary" type="button">Upload files</button>
|
|
51
|
+
<input id="file-input" type="file" multiple hidden>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Current path"></nav>
|
|
56
|
+
|
|
57
|
+
<div id="drop-zone" class="drop-zone">
|
|
58
|
+
<span>Drop files here to upload</span>
|
|
59
|
+
<small>Existing files will not be overwritten</small>
|
|
60
|
+
</div>
|
|
61
|
+
<div id="upload-list" class="upload-list" aria-live="polite"></div>
|
|
62
|
+
|
|
63
|
+
<div class="table-wrap">
|
|
64
|
+
<table>
|
|
65
|
+
<thead>
|
|
66
|
+
<tr><th>Name</th><th>Size</th><th>Modified</th><th><span class="sr-only">Actions</span></th></tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody id="file-list"></tbody>
|
|
69
|
+
</table>
|
|
70
|
+
<div id="empty-state" class="empty-state" hidden>This folder is empty</div>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
</main>
|
|
74
|
+
|
|
75
|
+
<dialog id="folder-dialog">
|
|
76
|
+
<form id="folder-form" method="dialog">
|
|
77
|
+
<h2>New folder</h2>
|
|
78
|
+
<label for="folder-name">Folder name</label>
|
|
79
|
+
<input id="folder-name" name="name" maxlength="255" autocomplete="off" required>
|
|
80
|
+
<div class="dialog-actions">
|
|
81
|
+
<button class="button secondary" value="cancel" type="button" id="cancel-folder">Cancel</button>
|
|
82
|
+
<button class="button primary" value="create" type="submit">Create</button>
|
|
83
|
+
</div>
|
|
84
|
+
</form>
|
|
85
|
+
</dialog>
|
|
86
|
+
|
|
87
|
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|