Skip to content

Commit f780b7b

Browse files
authored
Merge pull request #47 from hackclub/staging
Staging
2 parents 06ea100 + e68a497 commit f780b7b

File tree

8 files changed

+258
-39
lines changed

8 files changed

+258
-39
lines changed

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@ IDV_CLIENT_SECRET=changeme
2929

3030
SENTRY_AUTH_TOKEN=changeme
3131

32-
# AIRTABLE_TOKEN=abcxyz # optional
32+
# AIRTABLE_TOKEN=abcxyz # optional
33+
34+
PRINTABLES_ALLOWED_LICENSES_ID=7,1,2,9,12,10,11 # License IDs from printables

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ You can also run this to get a GUI for the database:
4343
npm run db:studio
4444
```
4545

46+
### Printables license IDs
47+
48+
Shipping checks licenses by ID. Grab the IDs directly from the Printables GraphQL API:
49+
50+
```json
51+
{
52+
"operationName": "Licenses",
53+
"query": "query Licenses {\n licenses {\n id\n name\n abbreviation\n content\n disallowRemixing\n freeModels\n storeModels\n allowedLicensesAfterRemixing {\n id\n __typename\n }\n __typename\n }\n}",
54+
"variables": {}
55+
}
56+
```
57+
58+
Use the returned `id` values in `PRINTABLES_ALLOWED_LICENSES_ID` (comma-separated) in your `.env`.
59+
4660
## Deploying
4761

4862
Create a `.env` file containing all the required credentials, look at `.env.example` for an example. If you pass in Airtable tokens, the app must be marked as `hq_official` on idv.

src/lib/components/Spinny3DPreview.svelte

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
let controls: OrbitControls | null = null;
2121
let camera: THREE.PerspectiveCamera | null = null;
2222
let animationId: number | null = null;
23+
let isVisible: boolean = false;
24+
let isModelLoaded: boolean = false;
25+
let containerElement: HTMLDivElement | null = null;
26+
let observer: IntersectionObserver | null = null;
2327
2428
function loadModel() {
2529
if (!modelUrl) {
@@ -56,7 +60,7 @@
5660
controls.rotateSpeed = 0.5;
5761
controls.dampingFactor = 0.1;
5862
controls.enableDamping = true;
59-
controls.autoRotate = true;
63+
controls.autoRotate = false;
6064
controls.autoRotateSpeed = 4;
6165
controls.update();
6266
@@ -286,15 +290,93 @@
286290
);
287291
288292
const animate = function () {
293+
if (!isVisible) {
294+
animationId = null;
295+
return;
296+
}
297+
289298
animationId = requestAnimationFrame(animate);
290-
controls?.update();
299+
300+
if (controls && isVisible) {
301+
controls.autoRotate = true;
302+
controls.update();
303+
}
304+
291305
renderer?.render(scene, camera!);
292306
resizeCanvasToDisplaySize();
293307
};
294-
animate();
308+
309+
isModelLoaded = true;
310+
if (isVisible) {
311+
animate();
312+
}
313+
}
314+
315+
function startAnimation() {
316+
if (animationId === null && isModelLoaded && isVisible && renderer && controls && camera) {
317+
const animate = function () {
318+
if (!isVisible) {
319+
animationId = null;
320+
return;
321+
}
322+
323+
animationId = requestAnimationFrame(animate);
324+
325+
if (controls && isVisible) {
326+
controls.autoRotate = true;
327+
controls.update();
328+
}
329+
330+
renderer?.render(scene, camera!);
331+
332+
const canvas = renderer?.domElement;
333+
const width = canvas?.clientWidth;
334+
const height = canvas?.clientHeight;
335+
if (canvas?.width !== width || canvas?.height !== height) {
336+
renderer?.setSize(width ?? 0, height ?? 0, false);
337+
renderer?.setPixelRatio(window.devicePixelRatio);
338+
if (camera) camera.aspect = (width ?? 1) / (height ?? 1);
339+
camera?.updateProjectionMatrix();
340+
}
341+
};
342+
animate();
343+
}
344+
}
345+
346+
function stopAnimation() {
347+
if (animationId !== null) {
348+
cancelAnimationFrame(animationId);
349+
animationId = null;
350+
}
351+
if (controls) {
352+
controls.autoRotate = false;
353+
}
295354
}
296355
297356
onMount(() => {
357+
observer = new IntersectionObserver(
358+
(entries) => {
359+
entries.forEach((entry) => {
360+
const wasVisible = isVisible;
361+
isVisible = entry.isIntersecting;
362+
363+
if (isVisible && !wasVisible && isModelLoaded) {
364+
startAnimation();
365+
} else if (!isVisible && wasVisible) {
366+
stopAnimation();
367+
}
368+
});
369+
},
370+
{
371+
threshold: 0.01,
372+
rootMargin: '50px'
373+
}
374+
);
375+
376+
if (containerElement) {
377+
observer.observe(containerElement);
378+
}
379+
298380
fileSizeFromUrl(modelUrl).then((size) => {
299381
fileSize = size;
300382
@@ -307,8 +389,11 @@
307389
});
308390
309391
onDestroy(() => {
310-
if (animationId !== null) {
311-
cancelAnimationFrame(animationId);
392+
stopAnimation();
393+
394+
if (observer) {
395+
observer.disconnect();
396+
observer = null;
312397
}
313398
314399
controls?.dispose();
@@ -335,10 +420,11 @@
335420
renderer = null;
336421
controls = null;
337422
camera = null;
423+
containerElement = null;
338424
});
339425
</script>
340426

341-
<div class="relative h-full w-full">
427+
<div class="relative h-full w-full" bind:this={containerElement}>
342428
{#if loadedPercent < 100}
343429
<div class="center flex">
344430
{#if showLoadButton}

src/routes/dashboard/admin/admin/stats/+page.server.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export async function load({ locals }) {
1717
total: {
1818
clay: sql<number>`sum(${user.clay})`,
1919
brick: sql<number>`sum(${user.brick})`,
20-
shopScore: sql<number>`sum(${user.shopScore})`,
20+
shopScore: sql<number>`sum(${user.shopScore})`
2121
},
2222
average: {
2323
clay: sql<number>`avg(${user.clay})`,
2424
brick: sql<number>`avg(${user.brick})`,
25-
shopScore: sql<number>`avg(${user.shopScore})`,
25+
shopScore: sql<number>`avg(${user.shopScore})`
2626
}
2727
})
2828
.from(user);
@@ -50,13 +50,6 @@ export async function load({ locals }) {
5050
.from(project)
5151
.where(eq(project.deleted, false));
5252

53-
const [shippedProjectCount] = await db
54-
.select({
55-
count: count()
56-
})
57-
.from(project)
58-
.where(and(eq(project.deleted, false), ne(project.status, 'building')));
59-
6053
const [devlogs] = await db
6154
.select({
6255
count: count(),
@@ -66,11 +59,31 @@ export async function load({ locals }) {
6659
.from(devlog)
6760
.where(eq(devlog.deleted, false));
6861

62+
const shippedProjects = db
63+
.select({
64+
id: project.id,
65+
timeSpent: sql<number>`COALESCE(SUM(${devlog.timeSpent}), 0)`.as('time_spent'),
66+
devlogCount: sql<number>`COALESCE(COUNT(${devlog.id}), 0)`.as('devlog_count')
67+
})
68+
.from(project)
69+
.leftJoin(devlog, and(eq(project.id, devlog.projectId), eq(devlog.deleted, false)))
70+
.where(and(eq(project.deleted, false), ne(project.status, 'building')))
71+
.groupBy(project.id)
72+
.as('shippedProjects');
73+
74+
const [shippedStats] = await db.select({
75+
count: count(),
76+
totalTimeSpent: sql<number>`sum(${shippedProjects.timeSpent})`,
77+
averageTimeSpent: sql<number>`avg(${shippedProjects.timeSpent})`,
78+
totalDevlogs: sql<number>`sum(${shippedProjects.devlogCount})`,
79+
averageDevlogs: sql<number>`avg(${shippedProjects.devlogCount})`,
80+
}).from(shippedProjects);
81+
6982
return {
7083
users: users,
7184
project: projectCount,
7285
usersWithProjects,
73-
shippedProjectCount: shippedProjectCount.count,
86+
shippedStats,
7487
devlogs
7588
};
7689
}

src/routes/dashboard/admin/admin/stats/+page.svelte

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@
4242
<code>{data.devlogs.count}</code>
4343
</DataCard>
4444
<DataCard title="Shipped projects">
45-
<code>{data.shippedProjectCount}</code>
45+
<code>{data.shippedStats.count}</code>
46+
</DataCard>
47+
<DataCard title="Shipped devlogs">
48+
<code>{data.shippedStats.totalDevlogs}</code>
49+
</DataCard>
50+
<DataCard title="Devlogs per project">
51+
<code>{Math.round(data.shippedStats.averageDevlogs * 100) / 100}</code>
4652
</DataCard>
4753
</div>
4854
<h3 class="mt-1 text-xl font-semibold">By status</h3>
@@ -84,6 +90,12 @@
8490
<DataCard title="Total">
8591
{formatMinutes(data.devlogs.totalTime)}
8692
</DataCard>
93+
<DataCard title="Total (shipped)">
94+
{formatMinutes(data.shippedStats.totalTimeSpent)}
95+
</DataCard>
96+
<DataCard title="Average shipped time">
97+
{formatMinutes(data.shippedStats.averageTimeSpent)}
98+
</DataCard>
8799
<DataCard title="Average devlog time">
88100
{formatMinutes(data.devlogs.timePerDevlog)}
89101
</DataCard>

src/routes/dashboard/projects/[id]/ship/+page.server.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,67 @@ export const actions = {
9191
});
9292
}
9393

94+
const printablesUrlObj = new URL(printablesUrl.toString().trim());
95+
96+
const pathMatch = printablesUrlObj.pathname.match(/\/model\/(\d+)/);
97+
const modelId = pathMatch ? pathMatch[1] : '';
98+
99+
const allowedLicenseIds = (env.PRINTABLES_ALLOWED_LICENSES_ID ?? '7,1,2,9,12,10,11')
100+
.split(',')
101+
.map((s) => s.trim())
102+
.filter((s) => s.length > 0);
103+
if (allowedLicenseIds.length === 0) {
104+
return error(500, { message: 'license validation not configured' });
105+
}
106+
107+
try {
108+
const graphqlResponse = await fetch('https://api.printables.com/graphql/', {
109+
method: 'POST',
110+
headers: {
111+
'content-type': 'application/json'
112+
},
113+
body: JSON.stringify({
114+
operationName: 'PrintDetail',
115+
query: `query PrintDetail($id: ID!) {
116+
print(id: $id) {
117+
id
118+
name
119+
license {
120+
id
121+
name
122+
}
123+
}
124+
}`,
125+
variables: { id: modelId }
126+
})
127+
});
128+
if (!graphqlResponse.ok) {
129+
return fail(400, {
130+
invalid_printables_url: true
131+
});
132+
}
133+
const graphqlData = await graphqlResponse.json();
134+
const license = graphqlData?.data?.print?.license;
135+
136+
if (!license || !license.id) {
137+
return fail(400, {
138+
invalid_printables_url: true
139+
});
140+
}
141+
142+
const licenseMatch = allowedLicenseIds.some((allowed) => allowed === license.id.toString());
143+
144+
if (!licenseMatch) {
145+
return fail(400, {
146+
invalid_license: true
147+
});
148+
}
149+
} catch {
150+
return fail(400, {
151+
invalid_printables_url: true
152+
});
153+
}
154+
94155
// Editor URL
95156
const editorUrlExists = editorUrl && editorUrl.toString();
96157
const editorUrlValid =
@@ -134,7 +195,7 @@ export const actions = {
134195
'application/octet-stream',
135196
'text/plain'
136197
].includes(modelFile.type);
137-
198+
138199
if (!modelFileValid) {
139200
return fail(400, {
140201
invalid_model_file: true

src/routes/dashboard/projects/[id]/ship/+page.svelte

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,16 @@
6262
name="printables_url"
6363
placeholder="https://www.printables.com/model/244385-flying-orpheus"
6464
bind:value={printablesUrl}
65-
class="themed-input-on-box active:ring-3"
65+
class={`themed-input-on-box ${form?.invalid_printables_url || form?.invalid_license ? 'border-primary-500' : ''}`}
6666
/>
6767
</label>
68-
{#if form?.invalid_printables_url}
69-
<p class="text-sm">Invalid URL</p>
70-
{/if}
68+
<div class="mt-1 text-primary-500">
69+
{#if form?.invalid_printables_url}
70+
<p class="text-sm">Invalid Printables URL</p>
71+
{:else if form?.invalid_license}
72+
<p class="text-sm">License not allowed, see below! You don't want Orpheus chasing you, do you?</p>
73+
{/if}
74+
</div>
7175
</div>
7276

7377
<div class="mt-2">
@@ -81,11 +85,11 @@
8185
name="editor_url"
8286
placeholder="https://cad.onshape.com/documents/14f82e23135e1e8bfe2305e0/w/28766465f00bd1d2079ae445/e/1d112b7ff9c457c1556814fd"
8387
bind:value={editorUrl}
84-
class="themed-input-on-box active:ring-3"
88+
class="themed-input-on-box"
8589
/>
8690
</label>
8791
{#if form?.invalid_editor_url}
88-
<p class="text-sm">Invalid URL</p>
92+
<p class="mt-1 text-sm">Invalid URL</p>
8993
{/if}
9094
</div>
9195

@@ -110,7 +114,7 @@
110114
>
111115
</div>
112116
</div>
113-
<div class="mt-0.5">
117+
<div class="mt-1">
114118
{#if form?.invalid_editor_file}
115119
<p class="text-sm">
116120
Invalid file, must be under {MAX_UPLOAD_SIZE / 1024 / 1024} MiB. Provide a link instead!

0 commit comments

Comments
 (0)