@ -5,14 +5,17 @@ let projects = [];
let config = { importers : { } } ;
let config = { importers : { } } ;
let stats = { trash : 0 , duplicateGroups : 0 , fingerprintsPending : 0 } ;
let stats = { trash : 0 , duplicateGroups : 0 , fingerprintsPending : 0 } ;
let selectedScope = 'all' ; // 'all' | 'duplicates' | 'trash' | a collection id
let selectedScope = 'all' ; // 'all' | 'duplicates' | 'trash' | a collection id
let spools = [ ] ; // filament on hand, so cards can say what's printable
let tagFilters = { } ; // tag -> 'include' | 'exclude'
let tagFilters = { } ; // tag -> 'include' | 'exclude'
let duplicateData = null ; // last /api/duplicates response
let duplicateData = null ; // last /api/duplicates response
let duplicatePoll = null ;
let duplicatePoll = null ;
// View preferences, remembered per browser.
// View preferences, remembered per browser.
const prefs = { view : 'grid' , tagMatch : 'any' , format : '' , source : '' , license : '' , sort : 'newest' , group : '' , slicer : '' , slicerTemplate : '' , searchMode : 'words' } ;
const prefs = { view : 'grid' , tagMatch : 'any' , format : '' , source : '' , license : '' , printable : '' , sort : 'newest' , group : '' , slicer : '' , slicerTemplate : '' , searchMode : 'words' } ;
const PREFS _KEY = 'printhive.prefs' ;
const PREFS _KEY = 'printhive.prefs' ;
const SPECIAL _SCOPES = [ 'all' , 'duplicates' , 'trash' , 'ai' , 'watch' ] ;
// Scopes that aren't a collection id. Leaving one out means loadData() decides
// the collection vanished and drops you back to All Models on the next refresh.
const SPECIAL _SCOPES = [ 'all' , 'duplicates' , 'trash' , 'ai' , 'watch' , 'filament' ] ;
// DOM
// DOM
const fileGrid = document . getElementById ( 'fileGrid' ) ;
const fileGrid = document . getElementById ( 'fileGrid' ) ;
@ -144,16 +147,18 @@ async function handleLogout() {
async function loadData ( ) {
async function loadData ( ) {
try {
try {
const [ modelsRes , projectsRes , configRes , statsRes ] = await Promise . all ( [
const [ modelsRes , projectsRes , configRes , statsRes , filamentRes ] = await Promise . all ( [
fetch ( '/api/models' ) ,
fetch ( '/api/models' ) ,
fetch ( '/api/projects' ) ,
fetch ( '/api/projects' ) ,
fetch ( '/api/config' ) ,
fetch ( '/api/config' ) ,
fetch ( '/api/library/stats' ) ,
fetch ( '/api/library/stats' ) ,
fetch ( '/api/filament' ) ,
] ) ;
] ) ;
models = await modelsRes . json ( ) ;
models = await modelsRes . json ( ) ;
projects = await projectsRes . json ( ) ;
projects = await projectsRes . json ( ) ;
config = await configRes . json ( ) ;
config = await configRes . json ( ) ;
stats = statsRes . ok ? await statsRes . json ( ) : stats ;
stats = statsRes . ok ? await statsRes . json ( ) : stats ;
spools = filamentRes . ok ? ( await filamentRes . json ( ) ) . spools : [ ] ;
if ( selectedScope === 'trash' ) await loadTrash ( ) ;
if ( selectedScope === 'trash' ) await loadTrash ( ) ;
if ( ! SPECIAL _SCOPES . includes ( selectedScope ) && ! projects . some ( ( p ) => String ( p . id ) === String ( selectedScope ) ) ) {
if ( ! SPECIAL _SCOPES . includes ( selectedScope ) && ! projects . some ( ( p ) => String ( p . id ) === String ( selectedScope ) ) ) {
selectedScope = 'all' ; // the open collection was deleted
selectedScope = 'all' ; // the open collection was deleted
@ -253,6 +258,30 @@ const SOURCE_LABELS = {
const sourceKey = ( m ) => m . source _site || 'upload' ;
const sourceKey = ( m ) => m . source _site || 'upload' ;
const sourceLabel = ( key ) => SOURCE _LABELS [ key ] || key ;
const sourceLabel = ( key ) => SOURCE _LABELS [ key ] || key ;
const licenseKey = ( m ) => ( m . commercial _use === true ? 'sellable' : m . commercial _use === false ? 'noncommercial' : 'unknown' ) ;
const licenseKey = ( m ) => ( m . commercial _use === true ? 'sellable' : m . commercial _use === false ? 'noncommercial' : 'unknown' ) ;
// Can you print it with what's on the shelf? Mirrors lib/filaments.js on the
// server: material only (a different colour still prints), an empty spool
// doesn't count, and a model whose filaments were never read is "unknown"
// rather than "no" — only slicer projects name their filaments.
const filamentType = ( s ) => String ( s || '' ) . trim ( ) . toUpperCase ( ) . replace ( /\s+/g , ' ' ) ;
function printability ( m ) {
const needed = [ ... new Set ( ( ( m && m . filaments ) || [ ] ) . map ( ( f ) => filamentType ( f . type ) ) . filter ( Boolean ) ) ] ;
if ( ! needed . length ) return { known : false , needed : [ ] , missing : [ ] , ok : false } ;
const onHand = new Set ( spools
. filter ( ( s ) => s . remaining _g == null || Number ( s . remaining _g ) > 0 )
. map ( ( s ) => filamentType ( s . material ) ) ) ;
const missing = needed . filter ( ( t ) => ! onHand . has ( t ) ) ;
return { known : true , needed , missing , ok : missing . length === 0 } ;
}
function printBadgeHtml ( m ) {
const p = printability ( m ) ;
if ( ! p . known ) return '' ;
return p . ok
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>'
: ` <span class="print-badge no" title="Not on the shelf: ${ escapeHtml ( p . missing . join ( ', ' ) ) } ">needs ${ escapeHtml ( p . missing . join ( ', ' ) ) } </span> ` ;
}
const totalSize = ( m ) => ( m . files || [ ] ) . reduce ( ( s , f ) => s + Number ( f . size || 0 ) , 0 ) ;
const totalSize = ( m ) => ( m . files || [ ] ) . reduce ( ( s , f ) => s + Number ( f . size || 0 ) , 0 ) ;
const fileCountLabel = ( m ) => ` ${ m . files . length } file ${ m . files . length === 1 ? '' : 's' } ` ;
const fileCountLabel = ( m ) => ` ${ m . files . length } file ${ m . files . length === 1 ? '' : 's' } ` ;
@ -416,6 +445,7 @@ function cardHtml(m) {
< div class = "file-name" > $ { escapeHtml ( m . name ) } < / d i v >
< div class = "file-name" > $ { escapeHtml ( m . name ) } < / d i v >
$ { m . source _site ? ` <span class="source-badge"> ${ escapeHtml ( m . source _site ) } </span> ` : '' }
$ { m . source _site ? ` <span class="source-badge"> ${ escapeHtml ( m . source _site ) } </span> ` : '' }
$ { m . commercial _use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m . commercial _use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : '' }
$ { m . commercial _use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m . commercial _use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : '' }
$ { printBadgeHtml ( m ) }
$ { m . description ? ` <div class="file-description"> ${ escapeHtml ( m . description ) } </div> ` : '' }
$ { m . description ? ` <div class="file-description"> ${ escapeHtml ( m . description ) } </div> ` : '' }
$ { ( m . tags && m . tags . length ) ? ` <div class="file-tags"> ${ m . tags . map ( ( t ) => ` <span class="file-tag"> ${ escapeHtml ( t ) } </span> ` ) . join ( '' ) } </div> ` : '' }
$ { ( m . tags && m . tags . length ) ? ` <div class="file-tags"> ${ m . tags . map ( ( t ) => ` <span class="file-tag"> ${ escapeHtml ( t ) } </span> ` ) . join ( '' ) } </div> ` : '' }
< div class = "file-meta" > $ { fileCountLabel ( m ) } • $ { formatFileSize ( totalSize ( m ) ) } • $ { when } < / d i v >
< div class = "file-meta" > $ { fileCountLabel ( m ) } • $ { formatFileSize ( totalSize ( m ) ) } • $ { when } < / d i v >
@ -456,6 +486,13 @@ function getFilteredModels() {
if ( prefs . format ) result = result . filter ( ( m ) => ( m . files || [ ] ) . some ( ( f ) => f . format === prefs . format ) ) ;
if ( prefs . format ) result = result . filter ( ( m ) => ( m . files || [ ] ) . some ( ( f ) => f . format === prefs . format ) ) ;
if ( prefs . source ) result = result . filter ( ( m ) => sourceKey ( m ) === prefs . source ) ;
if ( prefs . source ) result = result . filter ( ( m ) => sourceKey ( m ) === prefs . source ) ;
if ( prefs . license ) result = result . filter ( ( m ) => licenseKey ( m ) === prefs . license ) ;
if ( prefs . license ) result = result . filter ( ( m ) => licenseKey ( m ) === prefs . license ) ;
// Only models we've actually read filaments for can answer this either way.
if ( prefs . printable ) {
result = result . filter ( ( m ) => {
const p = printability ( m ) ;
return p . known && ( prefs . printable === 'yes' ? p . ok : ! p . ok ) ;
} ) ;
}
const terms = searchInput . value . toLowerCase ( ) . split ( /\s+/ ) . filter ( Boolean ) ;
const terms = searchInput . value . toLowerCase ( ) . split ( /\s+/ ) . filter ( Boolean ) ;
if ( terms . length ) {
if ( terms . length ) {
@ -699,10 +736,11 @@ function setupEventListeners() {
} ) ;
} ) ;
searchInput . addEventListener ( 'input' , renderModels ) ;
searchInput . addEventListener ( 'input' , renderModels ) ;
[ [ 'filterFormat' , 'format' ] , [ 'filterSource' , 'source' ] , [ 'filterLicense' , 'license' ] , [ 'sortBy' , 'sort' ] , [ 'groupBy' , 'group' ] ]
[ [ 'filterFormat' , 'format' ] , [ 'filterSource' , 'source' ] , [ 'filterLicense' , 'license' ] ,
[ 'filterPrintable' , 'printable' ] , [ 'sortBy' , 'sort' ] , [ 'groupBy' , 'group' ] ]
. forEach ( ( [ id , key ] ) => {
. forEach ( ( [ id , key ] ) => {
const el = document . getElementById ( id ) ;
const el = document . getElementById ( id ) ;
el . value = prefs [ key ] ;
el . value = prefs [ key ] || '' ; // a preference added later starts undefined
el . addEventListener ( 'change' , ( ) => { prefs [ key ] = el . value ; savePrefs ( ) ; renderModels ( ) ; } ) ;
el . addEventListener ( 'change' , ( ) => { prefs [ key ] = el . value ; savePrefs ( ) ; renderModels ( ) ; } ) ;
} ) ;
} ) ;
@ -2011,7 +2049,28 @@ function modelDragTarget(model) {
return { url : ` /api/models/ ${ model . id } /download ` , name : ` ${ ( model . name || 'model' ) . replace ( /[^\w.-]+/g , '_' ) } .zip ` } ;
return { url : ` /api/models/ ${ model . id } /download ` , name : ` ${ ( model . name || 'model' ) . replace ( /[^\w.-]+/g , '_' ) } .zip ` } ;
}
}
// What the shelf says about this model, shown between Source and License. The
// Filaments section further down lists what the project uses; this one answers
// whether you can print it today.
function renderDetailPrintable ( model ) {
const section = document . getElementById ( 'detailPrintableSection' ) ;
const el = document . getElementById ( 'detailPrintable' ) ;
if ( ! section || ! el ) return ;
const p = printability ( model ) ;
if ( ! p . known ) {
el . innerHTML = '<div class="lic-note">This model doesn’ t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>' ;
return ;
}
el . innerHTML = p . ok
? ` <div><span class="print-badge ok">✔ printable now</span></div>
< div class = "lic-note" > Needs $ { escapeHtml ( p . needed . join ( ', ' ) ) } — all on the shelf . < / d i v > `
: ` <div><span class="print-badge no">not right now</span></div>
< div class = "lic-note" > Needs $ { escapeHtml ( p . needed . join ( ', ' ) ) } ; you have no $ { escapeHtml ( p . missing . join ( ', ' ) ) } .
Colour isn ’ t matched — a different colour still prints . < / d i v > ` ;
}
function renderDetailLicense ( model ) {
function renderDetailLicense ( model ) {
renderDetailPrintable ( model ) ;
const com = model . commercial _use ; // true / false / null
const com = model . commercial _use ; // true / false / null
const badge = com === true
const badge = com === true
? '<span class="lic-badge ok">Commercial use allowed</span>'
? '<span class="lic-badge ok">Commercial use allowed</span>'
@ -2242,6 +2301,7 @@ async function refreshSpools() {
list . innerHTML = ` <p class="form-hint">Couldn’ t load your spools: ${ escapeHtml ( err . message ) } </p> ` ;
list . innerHTML = ` <p class="form-hint">Couldn’ t load your spools: ${ escapeHtml ( err . message ) } </p> ` ;
return ;
return ;
}
}
spools = data . spools ; // cards and the filter judge against this
const spoolCount = document . getElementById ( 'spoolCount' ) ;
const spoolCount = document . getElementById ( 'spoolCount' ) ;
if ( spoolCount ) spoolCount . textContent = data . spools . length ;
if ( spoolCount ) spoolCount . textContent = data . spools . length ;
list . innerHTML = data . spools . length
list . innerHTML = data . spools . length