mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Add external link field for archives (Issue #151)
Allow users to manually set external URLs for archives from Printables, Thingiverse, or other sources. The Globe button now opens the external link when set, falling back to auto-detected MakerWorld URL. - Add external_url field to PrintArchive model with migration - Add input field to archive edit modal - Update Globe button: external_url > makerworld_url > disabled - Include external_url in backup/restore - Add API test for external_url update - Update docs (changelog, wiki, website) Closes #151
This commit is contained in:
@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.1.6] - 2026-01-24
|
||||
|
||||
### New Features
|
||||
- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
|
||||
- Link archives to Printables, Thingiverse, or any other URL
|
||||
- Globe button opens external link when set, falls back to auto-detected MakerWorld URL
|
||||
- Edit via archive edit modal
|
||||
- Included in backup/restore
|
||||
- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
|
||||
- Configure per-printer external camera URL and type in Settings → Camera
|
||||
- Live streaming uses external camera when enabled
|
||||
|
||||
@@ -84,6 +84,7 @@ def archive_to_response(
|
||||
"extra_data": archive.extra_data,
|
||||
"makerworld_url": archive.makerworld_url,
|
||||
"designer": archive.designer,
|
||||
"external_url": archive.external_url,
|
||||
"is_favorite": archive.is_favorite,
|
||||
"tags": archive.tags,
|
||||
"notes": archive.notes,
|
||||
|
||||
@@ -616,6 +616,7 @@ async def export_backup(
|
||||
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
|
||||
"makerworld_url": a.makerworld_url,
|
||||
"designer": a.designer,
|
||||
"external_url": a.external_url,
|
||||
"is_favorite": a.is_favorite,
|
||||
"tags": a.tags,
|
||||
"notes": a.notes,
|
||||
@@ -1464,6 +1465,7 @@ async def import_backup(
|
||||
status=archive_data.get("status", "completed"),
|
||||
makerworld_url=archive_data.get("makerworld_url"),
|
||||
designer=archive_data.get("designer"),
|
||||
external_url=archive_data.get("external_url"),
|
||||
is_favorite=archive_data.get("is_favorite", False),
|
||||
tags=archive_data.get("tags"),
|
||||
notes=archive_data.get("notes"),
|
||||
|
||||
@@ -689,6 +689,12 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -43,10 +43,13 @@ class PrintArchive(Base):
|
||||
# Extended metadata (JSON blob for flexibility)
|
||||
extra_data: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
# MakerWorld info
|
||||
# MakerWorld info (auto-extracted from 3MF)
|
||||
makerworld_url: Mapped[str | None] = mapped_column(String(500))
|
||||
designer: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
# User-defined external link (Printables, Thingiverse, etc.)
|
||||
external_url: Mapped[str | None] = mapped_column(String(500))
|
||||
|
||||
# User additions
|
||||
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
tags: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -11,6 +11,7 @@ class ArchiveBase(BaseModel):
|
||||
cost: float | None = None
|
||||
failure_reason: str | None = None
|
||||
quantity: int | None = None # Number of items printed
|
||||
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
|
||||
|
||||
|
||||
class ArchiveUpdate(ArchiveBase):
|
||||
@@ -70,6 +71,7 @@ class ArchiveResponse(BaseModel):
|
||||
|
||||
makerworld_url: str | None
|
||||
designer: str | None
|
||||
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
|
||||
|
||||
is_favorite: bool
|
||||
tags: str | None
|
||||
|
||||
@@ -146,6 +146,28 @@ class TestArchivesAPI:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_favorite"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_archive_external_url(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Verify archive external_url can be updated."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id)
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["external_url"] == "https://printables.com/model/12345"
|
||||
|
||||
# Verify it can be cleared
|
||||
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["external_url"] is None
|
||||
|
||||
# ========================================================================
|
||||
# Delete endpoints
|
||||
# ========================================================================
|
||||
|
||||
@@ -259,6 +259,7 @@ export interface Archive {
|
||||
extra_data: Record<string, unknown> | null;
|
||||
makerworld_url: string | null;
|
||||
designer: string | null;
|
||||
external_url: string | null;
|
||||
is_favorite: boolean;
|
||||
tags: string | null;
|
||||
notes: string | null;
|
||||
@@ -1693,6 +1694,7 @@ export const api = {
|
||||
failure_reason?: string | null;
|
||||
status?: string;
|
||||
quantity?: number;
|
||||
external_url?: string | null;
|
||||
}) =>
|
||||
request<Archive>(`/archives/${id}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash } from 'lucide-react';
|
||||
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { Archive } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -51,6 +51,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
const [status, setStatus] = useState(archive.status);
|
||||
const [quantity, setQuantity] = useState(archive.quantity ?? 1);
|
||||
const [photos, setPhotos] = useState<string[]>(archive.photos || []);
|
||||
const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -155,6 +156,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
notes: notes || undefined,
|
||||
tags: tags || undefined,
|
||||
quantity: quantity,
|
||||
external_url: externalUrl || null,
|
||||
};
|
||||
|
||||
// Only include status if changed
|
||||
@@ -275,6 +277,24 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* External Link */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Link className="w-4 h-4 inline mr-1" />
|
||||
External Link
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
placeholder="https://printables.com/model/..."
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Link to Printables, Thingiverse, or other source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
|
||||
|
||||
@@ -293,10 +293,13 @@ function ArchiveCard({
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'View on MakerWorld',
|
||||
label: archive.external_url ? 'External Link' : 'View on MakerWorld',
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
|
||||
disabled: !archive.makerworld_url,
|
||||
onClick: () => {
|
||||
const url = archive.external_url || archive.makerworld_url;
|
||||
if (url) window.open(url, '_blank');
|
||||
},
|
||||
disabled: !archive.external_url && !archive.makerworld_url,
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
@@ -812,11 +815,20 @@ function ArchiveCard({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="min-w-0 p-1 sm:p-1.5"
|
||||
onClick={() => archive.makerworld_url && window.open(archive.makerworld_url, '_blank')}
|
||||
disabled={!archive.makerworld_url}
|
||||
title={archive.makerworld_url ? `MakerWorld: ${archive.designer || 'View project'}` : 'Not from MakerWorld'}
|
||||
onClick={() => {
|
||||
const url = archive.external_url || archive.makerworld_url;
|
||||
if (url) window.open(url, '_blank');
|
||||
}}
|
||||
disabled={!archive.external_url && !archive.makerworld_url}
|
||||
title={
|
||||
archive.external_url
|
||||
? 'External Link'
|
||||
: archive.makerworld_url
|
||||
? `MakerWorld: ${archive.designer || 'View project'}`
|
||||
: 'No external link'
|
||||
}
|
||||
>
|
||||
<Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.makerworld_url ? 'opacity-20' : ''}`} />
|
||||
<Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -1283,10 +1295,13 @@ function ArchiveListRow({
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'View on MakerWorld',
|
||||
label: archive.external_url ? 'External Link' : 'View on MakerWorld',
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
|
||||
disabled: !archive.makerworld_url,
|
||||
onClick: () => {
|
||||
const url = archive.external_url || archive.makerworld_url;
|
||||
if (url) window.open(url, '_blank');
|
||||
},
|
||||
disabled: !archive.external_url && !archive.makerworld_url,
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
@@ -1556,12 +1571,12 @@ function ArchiveListRow({
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
{archive.makerworld_url && (
|
||||
{(archive.external_url || archive.makerworld_url) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(archive.makerworld_url!, '_blank')}
|
||||
title="MakerWorld"
|
||||
onClick={() => window.open((archive.external_url || archive.makerworld_url)!, '_blank')}
|
||||
title={archive.external_url ? 'External Link' : 'MakerWorld'}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Du4KZbJd.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BQzscOA-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user