+5
-2
backend/src/backend/_internal/background.py
+5
-2
backend/src/backend/_internal/background.py
···
92
)
93
yield docket
94
finally:
95
+
# cancel the worker task with timeout to avoid hanging on shutdown
96
if worker_task:
97
worker_task.cancel()
98
try:
99
+
# wait briefly for clean shutdown, but don't block forever
100
+
await asyncio.wait_for(worker_task, timeout=2.0)
101
+
except TimeoutError:
102
+
logger.warning("docket worker did not stop within timeout")
103
except asyncio.CancelledError:
104
logger.debug("docket worker task cancelled")
105
# clear global after worker is fully stopped
+2
-1
backend/src/backend/api/tracks/metadata_service.py
+2
-1
backend/src/backend/api/tracks/metadata_service.py
···
6
from io import BytesIO
7
from typing import TYPE_CHECKING, Any
8
9
-
from fastapi import HTTPException, UploadFile
10
from sqlalchemy.ext.asyncio import AsyncSession
11
from sqlalchemy.orm import attributes
12
13
from backend._internal.atproto.handles import resolve_handle
14
from backend._internal.image import ImageFormat
···
6
from io import BytesIO
7
from typing import TYPE_CHECKING, Any
8
9
+
from fastapi import HTTPException
10
from sqlalchemy.ext.asyncio import AsyncSession
11
from sqlalchemy.orm import attributes
12
+
from starlette.datastructures import UploadFile
13
14
from backend._internal.atproto.handles import resolve_handle
15
from backend._internal.image import ImageFormat
+22
-4
backend/src/backend/api/tracks/mutations.py
+22
-4
backend/src/backend/api/tracks/mutations.py
···
180
Form(description="JSON object for supporter gating, or 'null' to remove"),
181
] = None,
182
image: UploadFile | None = File(None),
183
) -> TrackResponse:
184
"""Update track metadata (only by owner)."""
185
result = await db.execute(
···
250
251
image_changed = False
252
image_url = None
253
-
if image and image.filename:
254
image_id, image_url = await upload_track_image(image)
255
256
if track.image_id:
···
305
try:
306
await _update_atproto_record(track, auth_session, image_url)
307
except Exception as exc:
308
-
logger.error(
309
-
f"failed to update ATProto record for track {track.id}: {exc}",
310
-
exc_info=True,
311
)
312
await db.rollback()
313
raise HTTPException(
···
180
Form(description="JSON object for supporter gating, or 'null' to remove"),
181
] = None,
182
image: UploadFile | None = File(None),
183
+
remove_image: Annotated[
184
+
str | None,
185
+
Form(description="Set to 'true' to remove artwork"),
186
+
] = None,
187
) -> TrackResponse:
188
"""Update track metadata (only by owner)."""
189
result = await db.execute(
···
254
255
image_changed = False
256
image_url = None
257
+
258
+
# handle image removal
259
+
if remove_image and remove_image.lower() == "true" and track.image_id:
260
+
# only delete image from R2 if album doesn't share it
261
+
album_shares_image = (
262
+
track.album_rel and track.album_rel.image_id == track.image_id
263
+
)
264
+
if not album_shares_image:
265
+
with contextlib.suppress(Exception):
266
+
await storage.delete(track.image_id)
267
+
track.image_id = None
268
+
track.image_url = None
269
+
image_changed = True
270
+
elif image and image.filename:
271
+
# handle image upload/replacement
272
image_id, image_url = await upload_track_image(image)
273
274
if track.image_id:
···
323
try:
324
await _update_atproto_record(track, auth_session, image_url)
325
except Exception as exc:
326
+
logfire.exception(
327
+
"failed to update ATProto record",
328
+
track_id=track.id,
329
)
330
await db.rollback()
331
raise HTTPException(
+10
-3
backend/src/backend/main.py
+10
-3
backend/src/backend/main.py
···
1
"""relay fastapi application."""
2
3
+
import asyncio
4
import logging
5
import re
6
import warnings
···
158
app.state.docket = docket
159
yield
160
161
+
# shutdown: cleanup resources with timeouts to avoid hanging
162
+
try:
163
+
await asyncio.wait_for(notification_service.shutdown(), timeout=2.0)
164
+
except TimeoutError:
165
+
logging.warning("notification_service.shutdown() timed out")
166
+
try:
167
+
await asyncio.wait_for(queue_service.shutdown(), timeout=2.0)
168
+
except TimeoutError:
169
+
logging.warning("queue_service.shutdown() timed out")
170
171
172
app = FastAPI(
+359
-96
frontend/src/routes/portal/+page.svelte
+359
-96
frontend/src/routes/portal/+page.svelte
···
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
let editTags = $state<string[]>([]);
30
let editImageFile = $state<File | null>(null);
31
let editSupportGate = $state(false);
32
let hasUnresolvedEditFeaturesInput = $state(false);
33
···
328
editFeaturedArtists = [];
329
editTags = [];
330
editImageFile = null;
331
editSupportGate = false;
332
}
333
···
351
} else {
352
formData.append('support_gate', 'null');
353
}
354
-
if (editImageFile) {
355
formData.append('image', editImageFile);
356
}
357
···
730
/>
731
</div>
732
<div class="edit-field-group">
733
-
<label for="edit-image" class="edit-label">artwork (optional)</label>
734
-
{#if track.image_url && !editImageFile}
735
-
<div class="current-image-preview">
736
-
<img src={track.image_url} alt="current artwork" />
737
-
<span class="current-image-label">current artwork</span>
738
-
</div>
739
-
{/if}
740
-
<input
741
-
id="edit-image"
742
-
type="file"
743
-
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
744
-
onchange={(e) => {
745
-
const target = e.target as HTMLInputElement;
746
-
editImageFile = target.files?.[0] ?? null;
747
-
}}
748
-
class="edit-input"
749
-
/>
750
-
{#if editImageFile}
751
-
<p class="file-info">{editImageFile.name} (will replace current)</p>
752
-
{/if}
753
</div>
754
{#if atprotofansEligible || track.support_gate}
755
<div class="edit-field-group">
···
771
</div>
772
<div class="edit-actions">
773
<button
774
-
class="action-btn save-btn"
775
-
onclick={() => saveTrackEdit(track.id)}
776
-
disabled={hasUnresolvedEditFeaturesInput}
777
-
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
778
>
779
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
780
-
<polyline points="20 6 9 17 4 12"></polyline>
781
-
</svg>
782
</button>
783
<button
784
-
class="action-btn cancel-btn"
785
-
onclick={cancelEdit}
786
-
title="cancel"
787
>
788
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
789
-
<line x1="18" y1="6" x2="6" y2="18"></line>
790
-
<line x1="6" y1="6" x2="18" y2="18"></line>
791
-
</svg>
792
</button>
793
</div>
794
</div>
···
880
</div>
881
<div class="track-actions">
882
<button
883
-
class="action-btn edit-btn"
884
onclick={() => startEditTrack(track)}
885
-
title="edit track"
886
>
887
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
888
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
889
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
890
</svg>
891
</button>
892
<button
893
-
class="action-btn delete-btn"
894
onclick={() => deleteTrack(track.id, track.title)}
895
-
title="delete track"
896
>
897
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
898
<polyline points="3 6 5 6 21 6"></polyline>
899
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
900
</svg>
901
</button>
902
</div>
903
{/if}
···
1451
cursor: not-allowed;
1452
}
1453
1454
-
.file-info {
1455
-
margin-top: 0.5rem;
1456
-
font-size: var(--text-sm);
1457
-
color: var(--text-muted);
1458
-
}
1459
-
1460
-
button {
1461
width: 100%;
1462
padding: 0.75rem;
1463
background: var(--accent);
···
1471
transition: all 0.2s;
1472
}
1473
1474
-
button:hover:not(:disabled) {
1475
background: var(--accent-hover);
1476
transform: translateY(-1px);
1477
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
1478
}
1479
1480
-
button:disabled {
1481
opacity: 0.5;
1482
cursor: not-allowed;
1483
transform: none;
1484
}
1485
1486
-
button:active:not(:disabled) {
1487
transform: translateY(0);
1488
}
1489
···
1787
align-self: flex-start;
1788
}
1789
1790
-
.action-btn {
1791
-
display: flex;
1792
align-items: center;
1793
-
justify-content: center;
1794
-
width: 32px;
1795
-
height: 32px;
1796
-
padding: 0;
1797
background: transparent;
1798
border: 1px solid var(--border-default);
1799
-
border-radius: var(--radius-base);
1800
color: var(--text-tertiary);
1801
cursor: pointer;
1802
transition: all 0.15s;
1803
-
flex-shrink: 0;
1804
}
1805
1806
-
.action-btn svg {
1807
-
flex-shrink: 0;
1808
}
1809
1810
-
.action-btn:hover {
1811
transform: none;
1812
box-shadow: none;
1813
}
1814
1815
-
.edit-btn:hover {
1816
-
background: color-mix(in srgb, var(--accent) 12%, transparent);
1817
-
border-color: var(--accent);
1818
color: var(--accent);
1819
}
1820
1821
-
.delete-btn:hover {
1822
-
background: color-mix(in srgb, var(--error) 12%, transparent);
1823
-
border-color: var(--error);
1824
-
color: var(--error);
1825
}
1826
1827
-
.save-btn:hover {
1828
-
background: color-mix(in srgb, var(--success) 12%, transparent);
1829
-
border-color: var(--success);
1830
-
color: var(--success);
1831
-
}
1832
-
1833
-
.cancel-btn:hover {
1834
-
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
1835
-
border-color: var(--text-tertiary);
1836
-
color: var(--text-secondary);
1837
}
1838
1839
.edit-input {
···
1847
font-family: inherit;
1848
}
1849
1850
-
.current-image-preview {
1851
display: flex;
1852
align-items: center;
1853
-
gap: 0.75rem;
1854
-
padding: 0.5rem;
1855
background: var(--bg-primary);
1856
border: 1px solid var(--border-default);
1857
-
border-radius: var(--radius-sm);
1858
-
margin-bottom: 0.5rem;
1859
}
1860
1861
-
.current-image-preview img {
1862
-
width: 48px;
1863
-
height: 48px;
1864
-
border-radius: var(--radius-sm);
1865
object-fit: cover;
1866
}
1867
1868
-
.current-image-label {
1869
color: var(--text-tertiary);
1870
font-size: var(--text-sm);
1871
}
1872
1873
.edit-input:focus {
···
2424
.track-actions {
2425
margin-left: 0.5rem;
2426
gap: 0.35rem;
2427
}
2428
2429
-
.action-btn {
2430
-
width: 30px;
2431
-
height: 30px;
2432
}
2433
2434
-
.action-btn svg {
2435
-
width: 14px;
2436
-
height: 14px;
2437
}
2438
2439
/* edit mode mobile */
···
2455
}
2456
2457
.edit-actions {
2458
-
gap: 0.35rem;
2459
}
2460
2461
/* data section mobile */
···
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
let editTags = $state<string[]>([]);
30
let editImageFile = $state<File | null>(null);
31
+
let editImagePreviewUrl = $state<string | null>(null);
32
+
let editRemoveImage = $state(false);
33
let editSupportGate = $state(false);
34
let hasUnresolvedEditFeaturesInput = $state(false);
35
···
330
editFeaturedArtists = [];
331
editTags = [];
332
editImageFile = null;
333
+
if (editImagePreviewUrl) {
334
+
URL.revokeObjectURL(editImagePreviewUrl);
335
+
}
336
+
editImagePreviewUrl = null;
337
+
editRemoveImage = false;
338
editSupportGate = false;
339
}
340
···
358
} else {
359
formData.append('support_gate', 'null');
360
}
361
+
// handle artwork: remove, replace, or leave unchanged
362
+
if (editRemoveImage) {
363
+
formData.append('remove_image', 'true');
364
+
} else if (editImageFile) {
365
formData.append('image', editImageFile);
366
}
367
···
740
/>
741
</div>
742
<div class="edit-field-group">
743
+
<span class="edit-label">artwork (optional)</span>
744
+
<div class="artwork-editor">
745
+
{#if editImagePreviewUrl}
746
+
<!-- New image selected - show preview -->
747
+
<div class="artwork-preview">
748
+
<img src={editImagePreviewUrl} alt="new artwork preview" />
749
+
<div class="artwork-preview-overlay">
750
+
<button
751
+
type="button"
752
+
class="artwork-action-btn"
753
+
onclick={() => {
754
+
editImageFile = null;
755
+
if (editImagePreviewUrl) {
756
+
URL.revokeObjectURL(editImagePreviewUrl);
757
+
}
758
+
editImagePreviewUrl = null;
759
+
}}
760
+
title="remove selection"
761
+
>
762
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
763
+
<line x1="18" y1="6" x2="6" y2="18"></line>
764
+
<line x1="6" y1="6" x2="18" y2="18"></line>
765
+
</svg>
766
+
</button>
767
+
</div>
768
+
</div>
769
+
<span class="artwork-status">new artwork selected</span>
770
+
{:else if editRemoveImage}
771
+
<!-- User chose to remove artwork -->
772
+
<div class="artwork-removed">
773
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
774
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
775
+
<line x1="9" y1="9" x2="15" y2="15"></line>
776
+
<line x1="15" y1="9" x2="9" y2="15"></line>
777
+
</svg>
778
+
<span>artwork will be removed</span>
779
+
<button
780
+
type="button"
781
+
class="undo-remove-btn"
782
+
onclick={() => { editRemoveImage = false; }}
783
+
>
784
+
undo
785
+
</button>
786
+
</div>
787
+
{:else if track.image_url}
788
+
<!-- Current artwork exists -->
789
+
<div class="artwork-preview">
790
+
<img src={track.image_url} alt="current artwork" />
791
+
<div class="artwork-preview-overlay">
792
+
<button
793
+
type="button"
794
+
class="artwork-action-btn"
795
+
onclick={() => { editRemoveImage = true; }}
796
+
title="remove artwork"
797
+
>
798
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
799
+
<polyline points="3 6 5 6 21 6"></polyline>
800
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
801
+
</svg>
802
+
</button>
803
+
</div>
804
+
</div>
805
+
<span class="artwork-status current">current artwork</span>
806
+
{:else}
807
+
<!-- No artwork -->
808
+
<div class="artwork-empty">
809
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
810
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
811
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
812
+
<polyline points="21 15 16 10 5 21"></polyline>
813
+
</svg>
814
+
<span>no artwork</span>
815
+
</div>
816
+
{/if}
817
+
{#if !editRemoveImage}
818
+
<label class="artwork-upload-btn">
819
+
<input
820
+
type="file"
821
+
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
822
+
onchange={(e) => {
823
+
const target = e.target as HTMLInputElement;
824
+
const file = target.files?.[0];
825
+
if (file) {
826
+
editImageFile = file;
827
+
if (editImagePreviewUrl) {
828
+
URL.revokeObjectURL(editImagePreviewUrl);
829
+
}
830
+
editImagePreviewUrl = URL.createObjectURL(file);
831
+
}
832
+
}}
833
+
/>
834
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
835
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
836
+
<polyline points="17 8 12 3 7 8"></polyline>
837
+
<line x1="12" y1="3" x2="12" y2="15"></line>
838
+
</svg>
839
+
{track.image_url || editImagePreviewUrl ? 'replace' : 'upload'}
840
+
</label>
841
+
{/if}
842
+
</div>
843
</div>
844
{#if atprotofansEligible || track.support_gate}
845
<div class="edit-field-group">
···
861
</div>
862
<div class="edit-actions">
863
<button
864
+
type="button"
865
+
class="edit-cancel-btn"
866
+
onclick={cancelEdit}
867
>
868
+
cancel
869
</button>
870
<button
871
+
type="button"
872
+
class="edit-save-btn"
873
+
onclick={() => saveTrackEdit(track.id)}
874
+
disabled={hasUnresolvedEditFeaturesInput}
875
+
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
876
>
877
+
save changes
878
</button>
879
</div>
880
</div>
···
966
</div>
967
<div class="track-actions">
968
<button
969
+
type="button"
970
+
class="track-action-btn edit"
971
onclick={() => startEditTrack(track)}
972
>
973
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
974
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
975
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
976
</svg>
977
+
edit
978
</button>
979
<button
980
+
type="button"
981
+
class="track-action-btn delete"
982
onclick={() => deleteTrack(track.id, track.title)}
983
>
984
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
985
<polyline points="3 6 5 6 21 6"></polyline>
986
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
987
</svg>
988
+
delete
989
</button>
990
</div>
991
{/if}
···
1539
cursor: not-allowed;
1540
}
1541
1542
+
/* form submit buttons only */
1543
+
form button[type="submit"] {
1544
width: 100%;
1545
padding: 0.75rem;
1546
background: var(--accent);
···
1554
transition: all 0.2s;
1555
}
1556
1557
+
form button[type="submit"]:hover:not(:disabled) {
1558
background: var(--accent-hover);
1559
transform: translateY(-1px);
1560
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
1561
}
1562
1563
+
form button[type="submit"]:disabled {
1564
opacity: 0.5;
1565
cursor: not-allowed;
1566
transform: none;
1567
}
1568
1569
+
form button[type="submit"]:active:not(:disabled) {
1570
transform: translateY(0);
1571
}
1572
···
1870
align-self: flex-start;
1871
}
1872
1873
+
/* track action buttons (edit/delete in non-editing state) */
1874
+
.track-action-btn {
1875
+
display: inline-flex;
1876
align-items: center;
1877
+
gap: 0.35rem;
1878
+
padding: 0.4rem 0.65rem;
1879
background: transparent;
1880
border: 1px solid var(--border-default);
1881
+
border-radius: var(--radius-full);
1882
color: var(--text-tertiary);
1883
+
font-size: var(--text-sm);
1884
+
font-family: inherit;
1885
+
font-weight: 500;
1886
cursor: pointer;
1887
transition: all 0.15s;
1888
+
white-space: nowrap;
1889
+
width: auto;
1890
+
}
1891
+
1892
+
.track-action-btn:hover {
1893
+
transform: none;
1894
+
box-shadow: none;
1895
+
border-color: var(--border-emphasis);
1896
+
color: var(--text-secondary);
1897
+
}
1898
+
1899
+
.track-action-btn.delete:hover {
1900
+
color: var(--text-secondary);
1901
+
}
1902
+
1903
+
/* edit mode action buttons */
1904
+
.edit-actions {
1905
+
display: flex;
1906
+
gap: 0.75rem;
1907
+
justify-content: flex-end;
1908
+
padding-top: 0.75rem;
1909
+
border-top: 1px solid var(--border-subtle);
1910
+
margin-top: 0.5rem;
1911
}
1912
1913
+
.edit-cancel-btn {
1914
+
padding: 0.6rem 1.25rem;
1915
+
background: transparent;
1916
+
border: 1px solid var(--border-default);
1917
+
border-radius: var(--radius-base);
1918
+
color: var(--text-secondary);
1919
+
font-size: var(--text-base);
1920
+
font-weight: 500;
1921
+
font-family: inherit;
1922
+
cursor: pointer;
1923
+
transition: all 0.15s;
1924
+
width: auto;
1925
}
1926
1927
+
.edit-cancel-btn:hover {
1928
+
border-color: var(--text-tertiary);
1929
+
background: var(--bg-hover);
1930
transform: none;
1931
box-shadow: none;
1932
}
1933
1934
+
.edit-save-btn {
1935
+
padding: 0.6rem 1.25rem;
1936
+
background: transparent;
1937
+
border: 1px solid var(--accent);
1938
+
border-radius: var(--radius-base);
1939
color: var(--accent);
1940
+
font-size: var(--text-base);
1941
+
font-weight: 500;
1942
+
font-family: inherit;
1943
+
cursor: pointer;
1944
+
transition: all 0.15s;
1945
+
width: auto;
1946
}
1947
1948
+
.edit-save-btn:hover:not(:disabled) {
1949
+
background: color-mix(in srgb, var(--accent) 8%, transparent);
1950
}
1951
1952
+
.edit-save-btn:disabled {
1953
+
opacity: 0.5;
1954
+
cursor: not-allowed;
1955
}
1956
1957
.edit-input {
···
1965
font-family: inherit;
1966
}
1967
1968
+
/* artwork editor */
1969
+
.artwork-editor {
1970
display: flex;
1971
align-items: center;
1972
+
gap: 1rem;
1973
+
padding: 0.75rem;
1974
background: var(--bg-primary);
1975
border: 1px solid var(--border-default);
1976
+
border-radius: var(--radius-base);
1977
}
1978
1979
+
.artwork-preview {
1980
+
position: relative;
1981
+
width: 80px;
1982
+
height: 80px;
1983
+
border-radius: var(--radius-base);
1984
+
overflow: hidden;
1985
+
flex-shrink: 0;
1986
+
}
1987
+
1988
+
.artwork-preview img {
1989
+
width: 100%;
1990
+
height: 100%;
1991
object-fit: cover;
1992
}
1993
1994
+
.artwork-preview-overlay {
1995
+
position: absolute;
1996
+
inset: 0;
1997
+
background: rgba(0, 0, 0, 0.5);
1998
+
display: flex;
1999
+
align-items: center;
2000
+
justify-content: center;
2001
+
opacity: 0;
2002
+
transition: opacity 0.15s;
2003
+
}
2004
+
2005
+
.artwork-preview:hover .artwork-preview-overlay {
2006
+
opacity: 1;
2007
+
}
2008
+
2009
+
.artwork-action-btn {
2010
+
display: flex;
2011
+
align-items: center;
2012
+
justify-content: center;
2013
+
width: 32px;
2014
+
height: 32px;
2015
+
padding: 0;
2016
+
background: rgba(255, 255, 255, 0.15);
2017
+
border: none;
2018
+
border-radius: var(--radius-full);
2019
+
color: white;
2020
+
cursor: pointer;
2021
+
transition: all 0.15s;
2022
+
}
2023
+
2024
+
.artwork-action-btn:hover {
2025
+
background: var(--error);
2026
+
transform: scale(1.1);
2027
+
box-shadow: none;
2028
+
}
2029
+
2030
+
.artwork-status {
2031
+
font-size: var(--text-sm);
2032
+
color: var(--accent);
2033
+
font-weight: 500;
2034
+
}
2035
+
2036
+
.artwork-status.current {
2037
color: var(--text-tertiary);
2038
+
font-weight: 400;
2039
+
}
2040
+
2041
+
.artwork-removed {
2042
+
display: flex;
2043
+
flex-direction: column;
2044
+
align-items: center;
2045
+
gap: 0.5rem;
2046
+
padding: 0.75rem 1rem;
2047
+
color: var(--text-tertiary);
2048
+
}
2049
+
2050
+
.artwork-removed span {
2051
font-size: var(--text-sm);
2052
+
}
2053
+
2054
+
.undo-remove-btn {
2055
+
padding: 0.25rem 0.75rem;
2056
+
background: transparent;
2057
+
border: 1px solid var(--border-default);
2058
+
border-radius: var(--radius-full);
2059
+
color: var(--accent);
2060
+
font-size: var(--text-sm);
2061
+
font-family: inherit;
2062
+
cursor: pointer;
2063
+
transition: all 0.15s;
2064
+
width: auto;
2065
+
}
2066
+
2067
+
.undo-remove-btn:hover {
2068
+
border-color: var(--accent);
2069
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
2070
+
transform: none;
2071
+
box-shadow: none;
2072
+
}
2073
+
2074
+
.artwork-empty {
2075
+
display: flex;
2076
+
flex-direction: column;
2077
+
align-items: center;
2078
+
gap: 0.5rem;
2079
+
padding: 0.75rem 1rem;
2080
+
color: var(--text-muted);
2081
+
}
2082
+
2083
+
.artwork-empty span {
2084
+
font-size: var(--text-sm);
2085
+
}
2086
+
2087
+
.artwork-upload-btn {
2088
+
display: inline-flex;
2089
+
align-items: center;
2090
+
gap: 0.4rem;
2091
+
padding: 0.5rem 0.85rem;
2092
+
background: transparent;
2093
+
border: 1px solid var(--accent);
2094
+
border-radius: var(--radius-full);
2095
+
color: var(--accent);
2096
+
font-size: var(--text-sm);
2097
+
font-weight: 500;
2098
+
cursor: pointer;
2099
+
transition: all 0.15s;
2100
+
margin-left: auto;
2101
+
}
2102
+
2103
+
.artwork-upload-btn:hover {
2104
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
2105
+
}
2106
+
2107
+
.artwork-upload-btn input {
2108
+
display: none;
2109
}
2110
2111
.edit-input:focus {
···
2662
.track-actions {
2663
margin-left: 0.5rem;
2664
gap: 0.35rem;
2665
+
flex-direction: column;
2666
}
2667
2668
+
.track-action-btn {
2669
+
padding: 0.35rem 0.55rem;
2670
+
font-size: var(--text-xs);
2671
}
2672
2673
+
.track-action-btn svg {
2674
+
width: 12px;
2675
+
height: 12px;
2676
}
2677
2678
/* edit mode mobile */
···
2694
}
2695
2696
.edit-actions {
2697
+
gap: 0.5rem;
2698
+
flex-direction: column;
2699
+
}
2700
+
2701
+
.edit-cancel-btn,
2702
+
.edit-save-btn {
2703
+
width: 100%;
2704
+
padding: 0.6rem;
2705
+
font-size: var(--text-sm);
2706
+
}
2707
+
2708
+
/* artwork editor mobile */
2709
+
.artwork-editor {
2710
+
flex-direction: column;
2711
+
gap: 0.75rem;
2712
+
padding: 0.65rem;
2713
+
}
2714
+
2715
+
.artwork-preview {
2716
+
width: 64px;
2717
+
height: 64px;
2718
+
}
2719
+
2720
+
.artwork-upload-btn {
2721
+
margin-left: 0;
2722
}
2723
2724
/* data section mobile */