Skip to main content

max / makenotwork

18.7 KB · 423 lines History Blame Raw
1 {% extends "base.html" %}
2 {%- import "partials/carousel.html" as carousel -%}
3
4 {% block title %}{{ item.title }} - Makenotwork{% endblock %}
5 {% block body_attrs %} class="padded-page item-page"{% endblock %}
6
7 {% block head %}
8 <meta property="og:title" content="{{ item.title }} - {{ creator_username }}">
9 <meta property="og:description" content="{{ item.description }}">
10 <meta property="og:type" content="product">
11 <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}">
12 <link rel="canonical" href="{{ host_url }}/i/{{ item.id }}">
13 <meta property="og:site_name" content="Makenot.work">
14 <meta property="product:price:amount" content="{{ item.price }}">
15 {% if item.cover_image_url.is_some() || project_cover_image_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %}
16 <meta name="twitter:title" content="{{ item.title }} - {{ creator_username }}">
17 <meta name="twitter:description" content="{{ item.description }}">
18 {% if let Some(img) = item.cover_image_url %}
19 <meta property="og:image" content="{{ img }}">
20 <meta name="twitter:image" content="{{ img }}">
21 {% else if let Some(img) = project_cover_image_url %}
22 <meta property="og:image" content="{{ img }}">
23 <meta name="twitter:image" content="{{ img }}">
24 {% else %}
25 <meta property="og:image" content="{{ host_url }}/static/images/og-card.png">
26 <meta name="twitter:image" content="{{ host_url }}/static/images/og-card.png">
27 {% endif %}
28 <script type="application/ld+json">
29 {
30 "@context": "https://schema.org",
31 "@type": "Product",
32 "name": "{{ item.title_json()|safe }}",
33 "description": "{{ item.description_json()|safe }}",
34 "url": "{{ host_url }}/i/{{ item.id }}",
35 "brand": {
36 "@type": "Person",
37 "name": "{{ creator_username }}",
38 "url": "{{ host_url }}/u/{{ creator_username }}"
39 },
40 "offers": {
41 "@type": "Offer",
42 "price": "{{ item.price_decimal() }}",
43 "priceCurrency": "USD",
44 "availability": "https://schema.org/InStock",
45 "url": "{{ host_url }}/i/{{ item.id }}"
46 },
47 "category": "{{ item.item_type }}"{% if let Some(img) = item.cover_image_url %},
48 "image": "{{ img }}"{% endif %}
49 }
50 </script>
51 {% endblock %}
52
53 {% block content %}
54 {% include "partials/site_header.html" %}
55
56 <div class="container">
57 <nav class="breadcrumb">
58 <div>
59 <a href="/">Home</a> /
60 <a href="/u/{{ creator_username }}">{{ creator_username }}</a> /
61 <a href="/p/{{ project_slug }}">{{ project_title }}</a> /
62 <span>{{ item.title }}</span>
63 </div>
64 {% if is_owner %}
65 <a href="/dashboard/item/{{ item.id }}" class="breadcrumb-edit-link">Edit Item</a>
66 {% endif %}
67 </nav>
68
69 <div class="item-layout{% if item.cover_image_url.is_none() && item.item_type != "video" %} no-media{% endif %}">
70 {% match item.content %}
71 {% when ItemContent::Video with { video_s3_key, cover_url, .. } %}
72 <div class="item-media content-section">
73 <div id="video-container">
74 <video id="item-player" controls preload="metadata"
75 {% if let Some(url) = cover_url %}poster="{{ url }}"{% endif %}
76 class="video-player">
77 Your browser does not support the video tag.
78 </video>
79 </div>
80 </div>
81 {% when _ %}
82 {% if item.cover_image_url.is_some() %}
83 <div class="item-media content-section">
84 {% if let Some(img) = item.cover_image_url %}
85 <img src="{{ img }}" alt="{{ item.title }}" class="item-cover-img">
86 {% endif %}
87 </div>
88 {% endif %}
89 {% endmatch %}
90
91 <div class="item-details content-section">
92 <h1 class="item-title">{{ item.title }}</h1>
93 <div class="item-creator">
94 by <a href="/u/{{ creator_username }}">{{ creator_username }}</a>
95 </div>
96
97 <div class="item-price">{{ item.price }}</div>
98
99 {# AI disclosure tier — visible before purchase per
100 site-docs/public/about/generative-ai.md. #}
101 <div class="item-ai-tier">
102 <span class="badge ai-tier ai-tier-{{ item.ai_tier }}">{{ item.ai_tier.label() }}</span>
103 </div>
104
105 <div class="item-meta">
106 <div class="meta-item">
107 <div class="meta-label">Type</div>
108 <div class="meta-value">{{ item.item_type }}</div>
109 </div>
110 <div class="meta-item">
111 <div class="meta-label">Released</div>
112 <div class="meta-value">{{ item.release_date }}</div>
113 </div>
114 <div class="meta-item">
115 <div class="meta-label">Sales</div>
116 <div class="meta-value">{{ item.sales_count }}</div>
117 </div>
118 </div>
119
120 <div class="item-tags">
121 {% for tag in item.tags %}
122 <a href="/discover?tag={{ tag.slug }}" class="tag tag-link{% if tag.is_primary %} tag-primary{% endif %}">{{ tag.name }}</a>
123 {% endfor %}
124 </div>
125
126 {% if !item.listed %}
127 <div class="bundle-notice bundle-notice-inline">
128 <p class="bundle-notice-title">Included in a bundle</p>
129 <p class="bundle-notice-desc">This item isn't sold separately. You can get it as part of:</p>
130 {% for bundle in containing_bundles %}
131 <p class="bundle-notice-entry"><a href="/i/{{ bundle.id }}">{{ bundle.title }}</a> <span class="bundle-notice-price">{{ bundle.price }}</span></p>
132 {% endfor %}
133 {% if containing_bundles.is_empty() %}
134 <p class="bundle-notice-empty">No bundles currently available for this item.</p>
135 {% endif %}
136 </div>
137 {% else %}
138 {# Assisted items disclose AI use above the buy CTA so
139 fans see it before purchase. Handmade and Generated
140 carry the badge above; only Assisted owes prose. #}
141 {% if let crate::db::AiTier::Assisted = item.ai_tier %}
142 {% if let Some(disclosure) = item.ai_disclosure.as_ref() %}
143 <div class="ai-disclosure">
144 <div class="ai-disclosure-label">AI use</div>
145 <div class="ai-disclosure-text">{{ disclosure }}</div>
146 </div>
147 {% endif %}
148 {% endif %}
149
150 <div class="purchase-box card-muted">
151 <h3>What's included:</h3>
152 <ul>
153 {% if item.item_type == "bundle" %}
154 <li>{{ bundle_items.len() }} items in this bundle</li>
155 {% endif %}
156 <li>Lifetime access to all versions</li>
157 <li>Automatic update notifications</li>
158 <li>Direct creator support</li>
159 </ul>
160 </div>
161
162 {% if has_access %}
163 <a href="/l/{{ item.id }}" class="btn-primary">View in library &rarr;</a>
164 {% else if item.is_free %}
165 <button class="btn-primary"
166 hx-post="/api/library/add/{{ item.id }}"
167 hx-swap="outerHTML">Add to Library - Free</button>
168 {% else %}
169 <details class="promo-details">
170 <summary class="promo-summary">Have a promo code?</summary>
171 <form hx-post="/api/promo-codes/claim"
172 hx-swap="outerHTML"
173 class="promo-form">
174 <input type="hidden" name="item_id" value="{{ item.id }}">
175 <input type="text" name="code" placeholder="Enter code" required
176 class="promo-input">
177 <button class="btn-secondary nowrap" type="submit">Apply</button>
178 </form>
179 </details>
180
181 {% if item.pwyw_enabled %}
182 <a href="/purchase/{{ item.id }}" class="btn-primary">Pay What You Want - {{ item.price }}</a>
183 {% else %}
184 <a href="/purchase/{{ item.id }}" class="btn-primary">Buy Once - {{ item.price }}</a>
185 {% endif %}
186
187 <div class="payment-note">
188 Secure payment processing
189 </div>
190 {% endif %}
191 {% endif %}
192
193 {% if session_user.is_some() %}
194 {% if !is_owner %}
195 <div class="action-row">
196 <button class="btn-secondary full-width-btn" id="cart-btn"
197 onclick="toggleCart('{{ item.id }}')">{% if in_cart %}In Cart{% else %}Add to Cart{% endif %}</button>
198 </div>
199 <div class="action-row-tight">
200 <button class="btn-secondary full-width-btn" id="wishlist-btn"
201 onclick="toggleWishlist('{{ item.id }}')">{% if is_wishlisted %}Wishlisted{% else %}Add to Wishlist{% endif %}</button>
202 </div>
203 {% endif %}
204 <div class="action-row-tight collection-picker-anchor">
205 <button class="btn-secondary full-width-btn{% if collection_count > 0 %} saved{% endif %}"
206 data-collection-trigger data-item-id="{{ item.id }}" data-collection-label
207 onclick="openCollectionPicker('{{ item.id }}', this)">{% if collection_count > 0 %}Saved ({{ collection_count }}){% else %}Save to collection{% endif %}</button>
208 </div>
209 {% endif %}
210 </div>
211 </div>
212
213 {% if !gallery.is_empty() %}
214 <section class="item-gallery content-section">
215 {% call carousel::carousel("item-gallery", gallery) %}
216 </section>
217 {% endif %}
218
219 <section class="item-description content-section">
220 <h2 class="section-header">Description</h2>
221 <div>
222 <p>{{ item.description }}</p>
223 </div>
224 </section>
225
226 {% if !sections.is_empty() %}
227 <section class="item-sections content-section">
228 <div class="section-tabs">
229 {% for section in sections %}
230 <button class="section-tab{% if loop.first %} is-selected{% endif %}"
231 data-tab="section-{{ section.slug }}"
232 onclick="switchSectionTab(this, 'section-{{ section.slug }}')">{{ section.title }}</button>
233 {% endfor %}
234 </div>
235 {% for section in sections %}
236 <div class="section-panel{% if loop.first %} active{% endif %}" id="section-{{ section.slug }}">
237 {{ section.body_html|safe }}
238 </div>
239 {% endfor %}
240 </section>
241 {% endif %}
242
243 {% if item.license_preset.is_some() %}
244 <section class="item-description content-section">
245 <h2 class="section-header">License</h2>
246 <p class="license-preset-name">
247 {% if item.license_preset.as_deref() == Some("personal_use") %}Personal Use Only
248 {% else if item.license_preset.as_deref() == Some("royalty_free") %}Royalty-Free Commercial
249 {% else if item.license_preset.as_deref() == Some("mit") %}MIT License
250 {% else if item.license_preset.as_deref() == Some("apache2") %}Apache License 2.0
251 {% else if item.license_preset.as_deref() == Some("cc_by_4") %}CC BY 4.0
252 {% else if item.license_preset.as_deref() == Some("cc_by_nc_4") %}CC BY-NC 4.0
253 {% else if item.license_preset.as_deref() == Some("cc0") %}Public Domain (CC0)
254 {% else if item.license_preset.as_deref() == Some("custom") %}Custom License
255 {% else %}License
256 {% endif %}
257 </p>
258 <details>
259 <summary class="license-summary">View full license text</summary>
260 <pre class="license-text" id="license-text-content">Loading...</pre>
261 <script>
262 (function() {
263 var details = document.currentScript.closest('details');
264 var loaded = false;
265 details.addEventListener('toggle', function() {
266 if (details.open && !loaded) {
267 loaded = true;
268 fetch('/api/items/{{ item.id }}/license.txt')
269 .then(function(r) { return r.text(); })
270 .then(function(t) { document.getElementById('license-text-content').textContent = t; })
271 .catch(function() { document.getElementById('license-text-content').textContent = 'Failed to load license text.'; });
272 }
273 });
274 })();
275 </script>
276 </details>
277 <p class="license-download">
278 <a href="/api/items/{{ item.id }}/license.txt" download="LICENSE.txt">Download LICENSE.txt</a>
279 </p>
280 </section>
281 {% endif %}
282
283 {% if !bundle_items.is_empty() %}
284 <section class="bundle-contents content-section">
285 <h2 class="section-header">What's Included ({{ bundle_items.len() }} items)</h2>
286 {% for child in bundle_items %}
287 <div class="bundle-child list-row">
288 <span class="bundle-child-type">{{ child.item_type }}</span>
289 <span class="bundle-child-title">
290 {% if child.listed %}
291 <a href="/i/{{ child.id }}">{{ child.title }}</a>
292 {% else %}
293 {{ child.title }}
294 {% endif %}
295 </span>
296 {% if child.price_cents > 0 %}
297 <span class="bundle-child-price">{{ child.price }}</span>
298 {% else %}
299 <span class="bundle-child-price">Free</span>
300 {% endif %}
301 </div>
302 {% endfor %}
303 </section>
304 {% endif %}
305
306 {% if !containing_bundles.is_empty() && !item.listed %}
307 <section class="bundle-notice">
308 <p>This item is available as part of:</p>
309 {% for bundle in containing_bundles %}
310 <p class="bundle-list-entry"><a href="/i/{{ bundle.id }}">{{ bundle.title }} - {{ bundle.price }}</a></p>
311 {% endfor %}
312 </section>
313 {% endif %}
314
315 {% include "partials/discussion_section.html" %}
316
317 <footer class="item-footer">
318 <p>Powered by <a href="/">Makenot<span class="dot">.</span>work</a> &middot; Fair distribution for creatives of all kinds</p>
319 <p class="footer-links">
320 {% if is_owner %}
321 <a href="/dashboard/item/{{ item.id }}">Edit</a> &middot;
322 <a href="/dashboard/item/{{ item.id }}#embed">Embed</a> &middot;
323 {% endif %}
324 <a href="/i/{{ item.id }}" data-copy-link>Copy link</a> &middot;
325 {% if session_user.is_some() %}
326 <a href="javascript:void(0)" onclick="document.getElementById('report-modal').classList.remove('hidden')">Report this item</a> &middot;
327 {% else %}
328 <a href="/login">Report this item</a> &middot;
329 {% endif %}
330 <a href="/policy">Policy</a>
331 </p>
332 </footer>
333 </div>
334
335 {% if session_user.is_some() %}
336 {% let report_target_type = "item" %}
337 {% let report_target_id = item.id %}
338 {% let report_has_labels = true %}
339 {% include "partials/report_modal.html" %}
340 {% endif %}
341 {% endblock %}
342
343 {% block scripts %}
344 <script>
345 function switchSectionTab(btn, panelId) {
346 document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('is-selected'); });
347 document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); });
348 btn.classList.add('is-selected');
349 var panel = document.getElementById(panelId);
350 if (panel) panel.classList.add('active');
351 history.replaceState(null, '', '#' + panelId);
352 }
353
354 (function() {
355 var hash = window.location.hash.replace('#', '');
356 if (hash) {
357 var panel = document.getElementById(hash);
358 var tab = document.querySelector('[data-tab="' + hash + '"]');
359 if (panel && tab) switchSectionTab(tab, hash);
360 }
361 })();
362
363 (function() {
364 var player = document.getElementById('item-player');
365 if (player) {
366 var loaded = false;
367 player.addEventListener('play', function loadSrc() {
368 if (!loaded) {
369 loaded = true;
370 player.pause();
371 fetch('/api/stream/{{ item.id }}')
372 .then(function(r) {
373 if (!r.ok) throw new Error('Stream unavailable');
374 return r.json();
375 })
376 .then(function(data) {
377 player.src = data.stream_url;
378 player.play();
379 })
380 .catch(function(err) {
381 showToast(err.message || 'Could not load video');
382 });
383 }
384 }, { once: true });
385 }
386 })();
387 </script>
388
389 <script>
390 (function() {
391 window.toggleWishlist = function(itemId) {
392 var btn = document.getElementById('wishlist-btn');
393 fetch('/api/wishlists/' + itemId, { method: 'POST', headers: csrfHeaders() })
394 .then(function(r) { return r.json(); })
395 .then(function(data) {
396 if (data.wishlisted) {
397 btn.textContent = 'Wishlisted';
398 } else {
399 btn.textContent = 'Add to Wishlist';
400 }
401 });
402 };
403
404 window.toggleCart = function(itemId) {
405 var btn = document.getElementById('cart-btn');
406 fetch('/api/cart/' + itemId, { method: 'POST', headers: csrfHeaders() })
407 .then(function(r) { return r.json(); })
408 .then(function(data) {
409 if (data.in_cart) {
410 btn.textContent = 'In Cart';
411 showToast('Added to cart. Buying multiple items together saves the creator on processing fees.', 'info');
412 } else {
413 btn.textContent = 'Add to Cart';
414 }
415 })
416 .catch(function(err) {
417 showToast(err.message || 'Failed to update cart');
418 });
419 };
420 })();
421 </script>
422 {% endblock %}
423