Skip to main content
GameDev.net gamedev.net
🔒 Locked

website development - pricing

Started by Nibbles Jan 23, 2004 at 11:17 AM 8 replies 350+ views
Original Post
Nibbles
Nibbles
hiya, i''ve just started working on a website for a swim club which you can see the little progress i''ve made so far without any information from them other than a few page titles: Kenora Swimming Sharks (basically just a layout so far... criticism would be appreciated too) I just got an email from them asking about payments and since I personally know two of the people that are part of it, I don''t want to ask too much and sound crazy, but also enough to make it worth my while. I''m mainly asking this here so that once i give them my price, i''ll be able to say that after some searching around the internet and asking questions that this is the price that i came up with. thanks for any advice, Scott
ktuluorion
ktuluorion
200-300$
[Piebert Entertainment] [Ask The All-Knowing Oracle A Question]------------------------------------------------------------GDSFUBY GameDev Society For UnBanning YodaTheCodaIf you want to see yoda unbanned then put this in your sig ------------------------------------------------------------DAIAGA Dave Astle is a God Association. To join, put this in your sig!Founder and High Priest o
Imperil
Imperil
straight html and no backend?

I''d say about $100 US or so.
Sander
Sander
Most pro's I know ask about $55,- an hour. That's for websites with PHP/MySql support though. I ask $20,- an hour for the same thing. Usually I will judge the amount of time it will take me before I start and give that number to the client as an indication.

Example:
Intertio interimmanagement and consultancy
This website took us about 30 hours yielding a nice $600,- It has an extensive backend too to manage the page.

EDIT: Little point of comment on your site: It never finishes loading. Eighther you are sending some very strange headers or you don't close the &#106avascripts properly by a "return true" at the end.

Sander Maréchal
[Lone Wolves Game Development][RoboBlast][Articles][GD Emporium][Webdesign][E-mail]


[edited by - sander on January 23, 2004 12:48:02 PM]
CWizard
CWizard
I recently created a web site for a swimming club as well, although happily the design was made by someone else. In total we charged about USD 7,000. I dare say, though, that it was a bit more complex than the one you''re making; almost all of it are centered around databases (news, meets, calendar, groups, galleries, etc) with (somewhat) friendly interfaces.

Hmm... I don''t like making links, but it''s here (swedish).
alnite
alnite
OMFG HTML pwns C++!
CWizard
CWizard
quote:
Original post by Nibbles
damn, i like the layout on your site more than my shitty one.
It was some other guy that made the "main design", although I polished it and made the "document elements" (boxes, tables etc). There are quite a few things I dislike with it, but which are there because the client and "the other guy" wanted them (such as that awful &#106avascript menu, grr...).<br><BLOCKQUOTE><SPAN CLASS=smallfont>quote:<hr HEIGHT=1 noshade>ah well. though mine will be more complex &#111;nce i get the info that is supposed to go &#111;n it. <hr height=1 noshade></SPAN></BLOCKQUOTE>A few tips. I use a sort of mini-homepage datatype which contains some info and a list of news ("articles") and a list of sub-pages (also "articles"), which they can fully administrate themselves. &#79;n that site I use it for both groups and meets. Thus, if they arrange or go to a meet, they can put up info, image galleries, start lists, results, as well as news reports &#111;n the "meet page". There is no need to update anything by digging in HTML <img src="smile.gif" width=15 height=15 align=middle> </div> <footer class="mt-auto pt-3 border-t border-gray-100 dark:border-gray-700"> <div class="mb-3 forum-signature text-gray-400 dark:text-gray-500"><a href="http://www.profundis.se/files/gd-count-entry.html"></a></div> <div class="flex items-center justify-between flex-wrap gap-2 text-xs"> <div class="hidden sm:flex items-center gap-2 text-gray-400 dark:text-gray-500"> <time title="January 23, 2004 07:47 PM">Jan 23, 2004 at 7:47 PM</time> </div> <div class="flex items-center gap-1 sm:gap-2 flex-wrap text-xs"> <div class="reactions-group flex items-center gap-1" id="reactions-2294414"> <button type="button" hx-post="/forums/post/2294414/react/" hx-vals='{"type": "like"}' hx-target="#reactions-2294414" hx-swap="outerHTML" title="Like this post" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-rose-500 dark:hover:text-rose-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/> </svg> <span class="hidden sm:inline">Like</span> </button> <button type="button" hx-post="/forums/post/2294414/react/" hx-vals='{"type": "helpful"}' hx-target="#reactions-2294414" hx-swap="outerHTML" title="This is helpful" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-emerald-500 dark:hover:text-emerald-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/> </svg> <span class="hidden sm:inline">Helpful</span> </button> <button type="button" hx-post="/forums/post/2294414/react/" hx-vals='{"type": "thanks"}' hx-target="#reactions-2294414" hx-swap="outerHTML" title="Thanks for this" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-amber-500 dark:hover:text-amber-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/> </svg> <span class="hidden sm:inline">Thanks</span> </button> </div> <span class="hidden sm:inline text-gray-200 dark:text-gray-700 mx-1">|</span> <button class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-sky-600 dark:hover:text-sky-400 transition-colors" onclick="quotePost(2294414, 'CWizard')" title="Quote this post"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg> <span class="hidden sm:inline">Quote</span> </button> <button class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-sky-600 dark:hover:text-sky-400 transition-colors" onclick="copyPostUrl(2294414)" title="Copy link to this post"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg> <span class="hidden sm:inline">Link</span> </button> </div> </div> </footer> </div> </div> </div> </article> <article id="post-2294428" data-author-name="CWizard" class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col min-h-[200px]"> <div class="p-6 flex-1 flex flex-col"> <div class="flex gap-4 flex-1"> <aside class="hidden sm:block w-28 flex-shrink-0 text-center"> <a href="/profile/32837-cwizard" class="inline-block"> <span class="gdx-avatar w-12 h-12 rounded-full "> <span class="gdx-avatar__img gdx-avatar__fallback rounded-full cursor-pointer text-lg" data-user-tooltip data-member-id="32837"> <span aria-hidden="true">C</span> </span> </span> </a> <a href="/profile/32837-cwizard" class="mt-2 block text-sm font-medium text-gray-900 dark:text-white hover:text-sky-600 truncate" data-tooltip-target="tooltip-author-32837"> CWizard </a> <div class="flex flex-wrap justify-center gap-1 mt-1"> </div> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1"> <svg class="w-3 h-3 text-yellow-500" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg> 95 </p> </aside> <div class="flex-1 min-w-0 flex flex-col"> <div class="sm:hidden mb-4"> <div class="flex items-center gap-1.5"> <span class="gdx-avatar w-6 h-6 rounded-full ring-2 ring-white dark:ring-gray-800"> <img src="/static/media/user/generic-user.png" alt="CWizard" class="gdx-avatar__img rounded-full cursor-pointer object-cover" data-user-tooltip data-member-id="32837"> </span> <div> <a href="/profile/32837-cwizard" class="font-medium text-gray-900 dark:text-white hover:text-purple-600 text-xs" data-user-tooltip data-member-id="32837"> CWizard </a> <div class="text-[10px] text-gray-500 dark:text-gray-400"> <time>Jan 23, 2004 at 7:57 PM</time> </div> </div> </div> </div> <div id="post-content-2294428" class="prose dark:prose-invert max-w-none flex-1 pb-3"> <BLOCKQUOTE><SPAN CLASS=smallfont>quote:<hr HEIGHT=1 noshade><i>Original post by alnite </i> <br>OMFG HTML pwns <span class="whitespace-nowrap">C++</span>!<hr height=1 noshade></SPAN></BLOCKQUOTE>Nah, PHP pwns <span class="whitespace-nowrap">C++</span>! . . . . sometimes </div> <footer class="mt-auto pt-3 border-t border-gray-100 dark:border-gray-700"> <div class="mb-3 forum-signature text-gray-400 dark:text-gray-500"><a href="http://www.profundis.se/files/gd-count-entry.html"></a></div> <div class="flex items-center justify-between flex-wrap gap-2 text-xs"> <div class="hidden sm:flex items-center gap-2 text-gray-400 dark:text-gray-500"> <time title="January 23, 2004 07:57 PM">Jan 23, 2004 at 7:57 PM</time> </div> <div class="flex items-center gap-1 sm:gap-2 flex-wrap text-xs"> <div class="reactions-group flex items-center gap-1" id="reactions-2294428"> <button type="button" hx-post="/forums/post/2294428/react/" hx-vals='{"type": "like"}' hx-target="#reactions-2294428" hx-swap="outerHTML" title="Like this post" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-rose-500 dark:hover:text-rose-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/> </svg> <span class="hidden sm:inline">Like</span> </button> <button type="button" hx-post="/forums/post/2294428/react/" hx-vals='{"type": "helpful"}' hx-target="#reactions-2294428" hx-swap="outerHTML" title="This is helpful" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-emerald-500 dark:hover:text-emerald-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/> </svg> <span class="hidden sm:inline">Helpful</span> </button> <button type="button" hx-post="/forums/post/2294428/react/" hx-vals='{"type": "thanks"}' hx-target="#reactions-2294428" hx-swap="outerHTML" title="Thanks for this" class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-amber-500 dark:hover:text-amber-400"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/> </svg> <span class="hidden sm:inline">Thanks</span> </button> </div> <span class="hidden sm:inline text-gray-200 dark:text-gray-700 mx-1">|</span> <button class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-sky-600 dark:hover:text-sky-400 transition-colors" onclick="quotePost(2294428, 'CWizard')" title="Quote this post"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg> <span class="hidden sm:inline">Quote</span> </button> <button class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-sky-600 dark:hover:text-sky-400 transition-colors" onclick="copyPostUrl(2294428)" title="Copy link to this post"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg> <span class="hidden sm:inline">Link</span> </button> </div> </div> </footer> </div> </div> </div> </article> <div id="last-post"></div> </div> <aside class="my-5 overflow-hidden rounded-2xl border border-emerald-200 bg-white shadow-sm transition-all hover:border-emerald-300 hover:shadow-md dark:border-emerald-500/25 dark:bg-gray-900/60 dark:hover:border-emerald-400/50 dark:hover:bg-gray-900/80" data-commerce-surface="forum_topic_endcap" data-commerce-product="vulkan-3d-graphics-rendering-cookbook" data-commerce-reason="product_fallback:global" data-commerce-variant="compact_product" aria-label="Affiliate recommendation" > <a href="/resources/go/11/?surface=forum_topic_endcap" rel="sponsored nofollow noopener" target="_blank" class="group block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-950"> <div class="flex gap-4"> <img src="https://m.media-amazon.com/images/I/816234NxRdL._SL1500_.jpg" alt="" loading="lazy" class="h-20 w-20 flex-none rounded-xl bg-gray-100 object-cover shadow-sm ring-1 ring-gray-200 dark:bg-gray-950 dark:ring-white/10"> <div class="min-w-0 flex-1"> <div class="mb-2 flex flex-wrap items-center gap-2"> <span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300">Recommended resource</span> <span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:bg-white/5 dark:text-gray-400">Affiliate</span> </div> <h3 class="text-base font-bold leading-snug text-gray-950 line-clamp-2 group-hover:text-emerald-700 dark:text-white dark:group-hover:text-emerald-300">Vulkan 3D Graphics Rendering Cookbook</h3> <p class="mt-1 text-sm text-gray-500 line-clamp-1 dark:text-gray-400">Amazon &middot; Book</p> <p class="mt-2 text-sm leading-relaxed text-gray-600 line-clamp-2 dark:text-gray-300">Written by experts with decades of rendering experience, this cookbook equips you with practical, hands-on recipes to master modern 3D graphics development by using bindless Vulkan. Focusing on Vulkan 1.3, this second edition starts by setting up your development environment, and quickly transitions to building a robust 3D rendering framework using self-contained recipes. Each recipe helps you incrementally enhance your codebase, integrating a variety of 3D rendering techniques and algorithms into a cohesive project. You’ll get to grips with core techniques, such as glTF 2.0 physically based rendering, image-based lighting, and GPU-driven rendering. The chapters help you grasp advanced topics, including glTF animations, screen-space rendering techniques, and optimization strategies. You’ll also learn how to use glTF 2.0 advanced PBR extensions and handle complex geometry data, ensuring your rendering engine is both powerful and performant. These new additions will enable you to create dynamic and realistic 3D graphics environments, fully utilizing Vulkan’s capabilities. By the end of this 3D rendering book, you’ll have gained an improved understanding of best practices used in modern graphic APIs and be able to create fast and versatile 3D rendering frameworks.</p> </div> </div> <div class="mt-4 flex items-center justify-between gap-3"> <p class="min-w-0 text-[11px] leading-snug text-gray-400 dark:text-gray-500">As an Amazon Associate I earn from qualifying purchases.</p> <span class="inline-flex flex-none items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-bold text-white transition-colors group-hover:bg-emerald-700 dark:bg-emerald-400 dark:text-gray-950 dark:group-hover:bg-emerald-300"> View resource <svg class="ml-1.5 h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg> </span> </div> </a> </aside> <section class="mt-8 mb-8" id="reply"> <div class="bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 text-center"> <div class="flex justify-center mb-3"> <div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/> </svg> </div> </div> <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">Topic Locked</h3> <p class="text-gray-600 dark:text-gray-400"> This topic has been locked by a moderator. New replies are not allowed. </p> </div> </section> <div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-center text-gray-600 dark:text-gray-400"> <a href="/login/?next=/forums/topic/203214-website-development-pricing/" class="text-primary-600 hover:underline">Sign in</a> to reply to this topic. </div> </div> <aside> <div class="sticky-sidebar space-y-6"> <div class="d-flex flex-column justify-content-center align-items-center mb-3 ads-lazy mpu flex flex-col justify-center items-center mb-3" style="min-height:250px;" data-slot-id="LEGACY_BOX_SIDEBAR" data-tier="0" data-delivery="gam"> <div id="gpt-box-sidebar" class="gpt-ad-slot" data-gpt-path="/1004699/Ad_Box_Sidebar" data-gpt-position="sidebar_top"></div> <div class="text-uppercase advert-label uppercase tracking-wider text-[10px] text-gray-400 dark:text-gray-600 d-none" style="letter-spacing:1px">Advertisement</div> </div> <section class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <h3 class="font-bold text-gray-900 dark:text-white mb-4">Topic Info</h3> <ul class="space-y-3 text-sm"> <li class="flex items-center justify-between text-gray-500 dark:text-gray-400"> <span>Views</span> <span class="font-medium text-gray-900 dark:text-white">350+</span> </li> <li class="flex items-center justify-between text-gray-500 dark:text-gray-400"> <span>Replies</span> <span class="font-medium text-gray-900 dark:text-white">8</span> </li> <li class="flex items-center justify-between text-gray-500 dark:text-gray-400"> <span>Created</span> <span class="font-medium text-gray-900 dark:text-white">Jan 23, 2004</span> </li> <li class="flex items-center justify-between text-gray-500 dark:text-gray-400"> <span>Last Reply</span> <span class="font-medium text-gray-900 dark:text-white">Jan 23, 2004</span> </li> </ul> <div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700"> <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Participants (1)</h4> <div class="flex flex-wrap gap-1"> <a href="/profile/11396-nibbles" title="Nibbles" class="hover:opacity-80 transition-opacity"> <span class="gdx-avatar w-6 h-6 rounded-full "> <span class="gdx-avatar__img gdx-avatar__fallback rounded-full cursor-pointer text-xs" data-user-tooltip data-member-id="11396"> <span aria-hidden="true">N</span> </span> </span> </a> </div> </div> </section> <div hx-get="/forums/topic/203214/related/" hx-trigger="revealed" hx-swap="outerHTML"> <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse"> <div class="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded mb-4"></div> <div class="space-y-3"> <div class="h-3 bg-gray-100 dark:bg-gray-700 rounded"></div> <div class="h-3 w-5/6 bg-gray-100 dark:bg-gray-700 rounded"></div> <div class="h-3 w-4/6 bg-gray-100 dark:bg-gray-700 rounded"></div> </div> </div> </div> <section class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <h3 class="font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2"> <svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/> </svg> Popular Topics </h3> <ul class="space-y-2"> <li class="group"> <a href="/forums/topic/720534-kaivern-a-persistent-rpg-built-around-one-living-world-looking-for-professional-feedback-on-technical-feasibility" class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1.5 rounded transition-colors"> <div class="flex items-start gap-1.5"> <span class="text-sm text-gray-900 dark:text-white group-hover:text-sky-600 line-clamp-2"> Kaivern - a persistent RPG built around one living world. Looking for professional feedback on technical feasibility </span> </div> <div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/> </svg> 7 </span> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> </svg> 450+ </span> <span class="truncate">Game Design and Theory</span> </div> </a> </li> <li class="group"> <a href="/forums/topic/720559-multithreading-scheduling-in-game-engines-with-dags" class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1.5 rounded transition-colors"> <div class="flex items-start gap-1.5"> <span class="flex-shrink-0 mt-0.5 text-orange-500 text-xs">🔥</span> <span class="text-sm text-gray-900 dark:text-white group-hover:text-sky-600 line-clamp-2"> Multithreading, scheduling in game engines with DAGs </span> </div> <div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/> </svg> 5 </span> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> </svg> 250+ </span> <span class="truncate">Engines and Middleware</span> </div> </a> </li> <li class="group"> <a href="/forums/topic/720440-ritual-before-work" class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1.5 rounded transition-colors"> <div class="flex items-start gap-1.5"> <span class="text-sm text-gray-900 dark:text-white group-hover:text-sky-600 line-clamp-2"> ritual before work </span> </div> <div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/> </svg> 5 </span> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> </svg> 250+ </span> <span class="truncate">GDNet Lounge</span> </div> </a> </li> <li class="group"> <a href="/forums/topic/720627-space-game" class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1.5 rounded transition-colors"> <div class="flex items-start gap-1.5"> <span class="flex-shrink-0 mt-0.5 text-orange-500 text-xs">🔥</span> <span class="text-sm text-gray-900 dark:text-white group-hover:text-sky-600 line-clamp-2"> Space Game </span> </div> <div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/> </svg> 4 </span> <span class="flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> </svg> 75+ </span> <span class="truncate">For Beginners</span> </div> </a> </li> </ul> </section> <section class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <h3 class="font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2"> <svg class="w-5 h-5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/> </svg> Browse Forums </h3> <nav class="space-y-1"> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">New to GameDev?</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/71-for-beginners" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> For Beginners </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Audio</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/18-music-and-sound-fx" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Music and Sound FX </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Business</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/23-games-business-and-law" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Games Business and Law </a> </li> <li> <a href="/forums/forum/21-games-career-development" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Games Career Development </a> </li> <li> <a href="/forums/forum/22-production-and-management" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Production and Management </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Game Design</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/19-game-design-and-theory" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Game Design and Theory </a> </li> <li> <a href="/forums/forum/20-writing-for-games" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Writing for Games </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Visual Arts</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/12-2d-and-3d-art" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> 2D and 3D Art </a> </li> <li> <a href="/forums/forum/13-art-critique-and-feedback" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Art Critique and Feedback </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Affiliates</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/34-angelcode" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> AngelCode </a> </li> <li> <a href="/forums/forum/33-nehe-productions" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> NeHe Productions </a> </li> </ul> </details> <details class="group" > <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Programming</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/6-artificial-intelligence" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Artificial Intelligence </a> </li> <li> <a href="/forums/forum/10-engines-and-middleware" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Engines and Middleware </a> </li> <li> <a href="/forums/forum/9-general-and-gameplay-programming" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> General and Gameplay Programming </a> </li> <li> <a href="/forums/forum/5-graphics-and-gpu-programming" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Graphics and GPU Programming </a> </li> <li> <a href="/forums/forum/7-math-and-physics" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Math and Physics </a> </li> <li> <a href="/forums/forum/8-networking-and-multiplayer" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Networking and Multiplayer </a> </li> </ul> </details> <details class="group" open> <summary class="flex items-center justify-between cursor-pointer py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="truncate">Community</span> <svg class="w-4 h-4 text-gray-400 group-open:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> </svg> </summary> <ul class="ml-3 mt-1 space-y-0.5 border-l-2 border-gray-100 dark:border-gray-700"> <li> <a href="/forums/forum/82-gamedev-challenges" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> GameDev Challenges </a> </li> <li> <a href="/forums/forum/26-gdnet-comments-suggestions-and-ideas" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> GDNet Comments, Suggestions, and Ideas </a> </li> <li> <a href="/forums/forum/25-gdnet-lounge" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-sky-600 font-medium bg-sky-50 dark:bg-sky-900/20"> GDNet Lounge </a> </li> <li> <a href="/forums/forum/29-hobby-project-classifieds" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Hobby Project Classifieds </a> </li> <li> <a href="/forums/forum/30-indie-showcase" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Indie Showcase </a> </li> <li> <a href="/forums/forum/28-your-announcements" class="block py-1 px-3 text-sm rounded-r hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-gray-600 dark:text-gray-400"> Your Announcements </a> </li> </ul> </details> </nav> </section> <section class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <h3 class="font-bold text-gray-900 dark:text-white mb-4">Quick Actions</h3> <ul class="space-y-2"> <li> <a href="/forums/forum/25-gdnet-lounge" class="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-sky-600 text-sm"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> </svg> Back to GDNet Lounge </a> </li> <li> <a href="/forums/" class="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-sky-600 text-sm"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/> </svg> All Forums </a> </li> </ul> </section> </div> </aside> </div> <div class="mt-8"> <div class="d-flex flex-column justify-content-center align-items-center mb-3 ads-lazy leaderboard flex flex-col justify-center items-center rounded mb-3" style="min-height:90px;" data-slot-id="LEGACY_LEADERBOARD_FOOTER" data-tier="0" data-delivery="gam"> <div id="gpt-leaderboard-footer" class="gpt-ad-slot" data-gpt-path="/1004699/Ad_Leaderboard_Footer" data-gpt-position="footer"></div> <div class="text-uppercase advert-label uppercase tracking-wider text-[10px] text-gray-400 dark:text-gray-600 d-none" style="letter-spacing:1px">Advertisement</div> </div> </div> </div> <script> // Pre-submit quality checks const LOW_EFFORT_PATTERNS = [ /^(thanks|thank you|thx|ty|ok|okay|yes|no|bump|same|this|agreed|\+1|me too|nice|cool|great|good|lol|haha)\.?$/i, /^.{1,15}$/ // Very short replies (less than 15 chars) ]; const SPAM_PATTERNS = [ /(https?:\/\/[^\s]+){3,}/i, // 3+ links /(.)\1{10,}/, // Same character repeated 10+ times /\b(buy|sale|discount|cheap|free|click here|visit|casino|crypto|nft)\b.*\b(buy|sale|discount|cheap|free|click here|visit|casino|crypto|nft)\b/i ]; function checkReplyQuality(content) { const text = content.replace(/<[^>]*>/g, '').trim(); // Strip HTML const issues = []; // Check for low-effort for (const pattern of LOW_EFFORT_PATTERNS) { if (pattern.test(text)) { issues.push({ type: 'low_effort', message: 'Your reply seems brief. Consider adding more detail to help the community.' }); break; } } // Check for spam patterns for (const pattern of SPAM_PATTERNS) { if (pattern.test(text)) { issues.push({ type: 'spam', message: 'Your reply contains patterns that may be flagged as spam.' }); break; } } // Check for empty content if (text.length === 0) { issues.push({ type: 'empty', message: 'Please write something before posting.' }); } return issues; } function showQualityNudge(issues, onContinue) { const modal = document.getElementById('quality-nudge-modal'); const issuesList = document.getElementById('quality-issues-list'); issuesList.innerHTML = issues.map(i => `<li class="flex items-start gap-2 text-amber-700 dark:text-amber-300"> <svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> </svg> <span>${i.message}</span> </li>` ).join(''); // Store callback for continue button window._qualityNudgeContinue = onContinue; modal.classList.remove('hidden'); } function closeQualityNudge() { document.getElementById('quality-nudge-modal').classList.add('hidden'); } function continuePostingAnyway() { closeQualityNudge(); if (window._qualityNudgeContinue) { window._qualityNudgeContinue(); } } // Sync TipTap editor content before form submit document.addEventListener('DOMContentLoaded', function() { const replyForm = document.querySelector('#reply-form'); if (replyForm) { // Sync editor content before form submit replyForm.addEventListener('submit', function(e) { if (window.gamedevEditors && window.gamedevEditors.length > 0) { window.gamedevEditors[0].syncToHiddenFields(); } }); // Also handle HTMX requests with quality check replyForm.addEventListener('htmx:beforeRequest', function(e) { if (window.gamedevEditors && window.gamedevEditors.length > 0) { window.gamedevEditors[0].syncToHiddenFields(); } // Quality check (skip if already bypassed) if (!replyForm.dataset.qualityBypassed) { const content = document.getElementById('content_html')?.value || ''; const issues = checkReplyQuality(content); // Only block for non-empty issues (empty is always blocked) const blockingIssues = issues.filter(i => i.type !== 'empty' || issues.length === 1); if (blockingIssues.length > 0 && blockingIssues[0].type !== 'empty') { e.preventDefault(); showQualityNudge(blockingIssues, function() { replyForm.dataset.qualityBypassed = 'true'; htmx.trigger(replyForm, 'submit'); delete replyForm.dataset.qualityBypassed; }); return false; } } }); // Handle successful reply submission replyForm.addEventListener('htmx:afterRequest', function(e) { if (e.detail.successful) { // Small delay to ensure DOM is ready setTimeout(function() { clearReplyEditor(); showToast('Reply posted!', 'success'); }, 100); } }); } }); // Clear editor and reset form function clearReplyEditor() { if (window.gamedevEditors && window.gamedevEditors.length > 0) { const gdEditor = window.gamedevEditors[0]; // Clear editor content if (gdEditor.editor) { gdEditor.editor.commands.setContent('', false); } // Clear autosave draft using the editor's method if (typeof gdEditor.clearDraft === 'function') { gdEditor.clearDraft(); } else if (gdEditor.autosaveKey) { localStorage.removeItem(gdEditor.autosaveKey); } // Sync to hidden field if (typeof gdEditor.syncToHiddenFields === 'function') { gdEditor.syncToHiddenFields(); } } // Also directly clear the hidden field const contentField = document.getElementById('content_html'); if (contentField) { contentField.value = ''; } } function quotePost(postId, authorName) { // Get selected text if any let textToQuote = window.getSelection().toString().trim(); // If no selection, get the post content if (!textToQuote && postId) { const postContentEl = document.getElementById(`post-content-${postId}`); if (postContentEl) { // Get text content, clean up whitespace textToQuote = postContentEl.innerText.trim().substring(0, 500); if (postContentEl.innerText.trim().length > 500) { textToQuote += '...'; } } } // Try to insert into TipTap editor if (window.gamedevEditors && window.gamedevEditors.length > 0) { const editor = window.gamedevEditors[0].editor; if (editor) { const content = `<blockquote><p><strong>${authorName}</strong> wrote:</p><p>${textToQuote || ''}</p></blockquote><p></p>`; editor.commands.insertContent(content); editor.commands.focus('end'); document.getElementById('reply')?.scrollIntoView({ behavior: 'smooth' }); return; } } // Fallback to textarea const textarea = document.getElementById('reply-content'); if (textarea && textarea.tagName === 'TEXTAREA') { const formattedQuote = textToQuote ? `> **${authorName}** wrote:\n> ${textToQuote.replace(/\n/g, '\n> ')}\n\n` : `@${authorName} `; textarea.value += formattedQuote; textarea.focus(); textarea.scrollIntoView({ behavior: 'smooth' }); } } function copyPostUrl(postId) { const url = window.location.origin + window.location.pathname + '#post-' + postId; navigator.clipboard.writeText(url).then(() => { // Show brief feedback const btn = event.target instanceof Element ? event.target.closest('button') : event.target.parentElement; const originalTitle = btn.title; btn.title = 'Copied!'; setTimeout(() => { btn.title = originalTitle; }, 2000); }); } // Floating quote selection popup (function() { let quotePopup = null; let currentPostId = null; let currentAuthorName = null; let currentSelectedText = null; // Store the selected text function createQuotePopup() { const popup = document.createElement('div'); popup.id = 'quote-selection-popup'; popup.style.cssText = 'position: fixed; z-index: 9999; display: none;'; popup.innerHTML = ` <button class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 text-white text-sm rounded-lg shadow-lg hover:bg-gray-700 dark:hover:bg-gray-600 transition-colors border border-gray-600"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/> </svg> Quote </button> `; popup.querySelector('button').addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); if (currentPostId !== null && currentAuthorName && currentSelectedText) { insertQuote(currentAuthorName, currentSelectedText); } hideQuotePopup(); }); document.body.appendChild(popup); return popup; } function insertQuote(authorName, text) { // Try to insert into TipTap editor if (window.gamedevEditors && window.gamedevEditors.length > 0) { const editor = window.gamedevEditors[0].editor; if (editor) { const content = `<blockquote><p><strong>${authorName}</strong> wrote:</p><p>${text}</p></blockquote><p></p>`; editor.commands.insertContent(content); editor.commands.focus('end'); document.getElementById('reply')?.scrollIntoView({ behavior: 'smooth' }); return; } } // Fallback to textarea const textarea = document.getElementById('reply-content'); if (textarea && textarea.tagName === 'TEXTAREA') { textarea.value += `> **${authorName}** wrote:\n> ${text.replace(/\n/g, '\n> ')}\n\n`; textarea.focus(); textarea.scrollIntoView({ behavior: 'smooth' }); } } function showQuotePopup(x, y, postId, authorName, selectedText) { if (!quotePopup) quotePopup = createQuotePopup(); currentPostId = postId; currentAuthorName = authorName; currentSelectedText = selectedText; // Position popup above selection quotePopup.style.left = `${Math.max(10, x)}px`; quotePopup.style.top = `${Math.max(10, y - 45)}px`; quotePopup.style.display = 'block'; } function hideQuotePopup() { if (quotePopup) { quotePopup.style.display = 'none'; } currentPostId = null; currentAuthorName = null; currentSelectedText = null; } // Listen for text selection in posts document.addEventListener('mouseup', (e) => { // Small delay to let selection finalize setTimeout(() => { const selection = window.getSelection(); if (!selection.rangeCount) { hideQuotePopup(); return; } const text = selection.toString().trim(); if (text.length < 10) { hideQuotePopup(); return; } // Check if selection is within a post content area const range = selection.getRangeAt(0); let node = range.commonAncestorContainer; if (node.nodeType === Node.TEXT_NODE) { node = node.parentElement; } // Find the post article element const postEl = node.closest('article[id^="post-"]'); if (!postEl) { hideQuotePopup(); return; } // Get post ID and author name from data attribute const postId = postEl.id.replace('post-', ''); const authorName = postEl.dataset.authorName || 'Unknown'; // Get selection coordinates const rect = range.getBoundingClientRect(); const x = rect.left + (rect.width / 2) - 40; const y = rect.top; // Store the selected text NOW before it gets cleared showQuotePopup(x, y, postId, authorName, text); }, 50); }); // Hide popup when clicking elsewhere document.addEventListener('mousedown', (e) => { if (quotePopup && !quotePopup.contains(e.target)) { hideQuotePopup(); } }); })(); // Mod drawer functions (staff and moderators) function openModDrawer() { const drawer = document.getElementById('mod-drawer'); drawer.classList.remove('translate-x-full'); // Load content via HTMX htmx.ajax('GET', '/forums/topic/203214/mod-notes/', {target: '#mod-drawer-content', swap: 'innerHTML'}); } function closeModDrawer() { document.getElementById('mod-drawer').classList.add('translate-x-full'); } // Post mod menu functions function openPostModMenu(postId) { const modal = document.getElementById('post-mod-modal'); const postIdInput = document.getElementById('post-mod-post-id'); postIdInput.value = postId; document.getElementById('post-mod-id-display').textContent = postId; modal.classList.remove('hidden'); } function closePostModMenu() { document.getElementById('post-mod-modal').classList.add('hidden'); } function executePostModAction(action) { const postId = document.getElementById('post-mod-post-id').value; fetch('/moderation/action/post/' + postId + '/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value }, body: 'action=' + action }).then(() => { closePostModMenu(); window.location.reload(); }); } </script> <div class="hidden md:block fixed bottom-4 right-4 z-10"> <button onclick="document.getElementById('shortcuts-modal').classList.remove('hidden')" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded shadow-sm border border-gray-200 dark:border-gray-700"> Press <kbd class="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-300">?</kbd> for shortcuts </button> </div> <script src="/static/gamedevnext/js/forum-shortcuts.a18f66b1e18e.js"></script> <script> /** * Toggle like on content * @param {string} contentType - Content type identifier * @param {number} contentId - Content ID * @param {HTMLElement} btn - Button element */ function toggleLike(contentType, contentId, btn) { if (btn.dataset.pending === 'true') return; btn.dataset.pending = 'true'; btn.disabled = true; const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.querySelector('meta[name="csrf-token"]')?.content || document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || ''; fetch(`/api/content/${contentType}/${contentId}/like/`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', }, }) .then(response => response.json()) .then(data => { if (data.message === 'OK') { const svg = btn.querySelector('svg'); const countSpan = btn.querySelector('.like-count'); if (data.vote > 0) { // Liked btn.classList.remove('text-gray-500', 'hover:text-pink-600', 'hover:bg-pink-50', 'dark:text-gray-400', 'dark:hover:text-pink-400', 'dark:hover:bg-pink-900/20'); btn.classList.add('bg-pink-100', 'text-pink-600', 'dark:bg-pink-900/30', 'dark:text-pink-400'); svg.classList.add('fill-current'); svg.setAttribute('fill', 'currentColor'); btn.title = 'Unlike'; } else { // Unliked btn.classList.add('text-gray-500', 'hover:text-pink-600', 'hover:bg-pink-50', 'dark:text-gray-400', 'dark:hover:text-pink-400', 'dark:hover:bg-pink-900/20'); btn.classList.remove('bg-pink-100', 'text-pink-600', 'dark:bg-pink-900/30', 'dark:text-pink-400'); svg.classList.remove('fill-current'); svg.setAttribute('fill', 'none'); btn.title = 'Like'; } if (countSpan && data.total !== undefined) { countSpan.textContent = data.total; } } else if (data.message) { // Show message (rate limit, self-like, etc.) showToast(data.message, 'info'); } else if (data.error) { showToast(data.error, 'error'); } }) .catch(err => { console.error('Like error:', err); showToast('Failed to update like', 'error'); }) .finally(() => { btn.dataset.pending = 'false'; btn.disabled = false; }); } /** * Toggle bookmark on content * @param {string} contentType - Content type identifier * @param {number} contentId - Content ID * @param {HTMLElement} btn - Button element */ function toggleBookmark(contentType, contentId, btn) { const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.querySelector('meta[name="csrf-token"]')?.content || document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || ''; fetch(`/api/content/${contentType}/${contentId}/bookmark/`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', }, }) .then(response => response.json()) .then(data => { if (data.bookmarked !== undefined) { const svg = btn.querySelector('svg'); const labelSpan = btn.querySelector('.bookmark-label'); if (data.bookmarked) { // Bookmarked btn.classList.remove('text-gray-500', 'hover:text-amber-600', 'hover:bg-amber-50', 'dark:text-gray-400', 'dark:hover:text-amber-400', 'dark:hover:bg-amber-900/20'); btn.classList.add('bg-amber-100', 'text-amber-600', 'dark:bg-amber-900/30', 'dark:text-amber-400'); svg.classList.add('fill-current'); svg.setAttribute('fill', 'currentColor'); btn.title = 'Remove bookmark'; if (labelSpan) labelSpan.textContent = 'Saved'; } else { // Unbookmarked btn.classList.add('text-gray-500', 'hover:text-amber-600', 'hover:bg-amber-50', 'dark:text-gray-400', 'dark:hover:text-amber-400', 'dark:hover:bg-amber-900/20'); btn.classList.remove('bg-amber-100', 'text-amber-600', 'dark:bg-amber-900/30', 'dark:text-amber-400'); svg.classList.remove('fill-current'); svg.setAttribute('fill', 'none'); btn.title = 'Save bookmark'; if (labelSpan) labelSpan.textContent = 'Save'; } } else if (data.error) { showToast(data.error, 'error'); } }) .catch(err => { console.error('Bookmark error:', err); showToast('Failed to update bookmark', 'error'); }); } /** * Simple toast notification (uses existing toast if available, else alert) */ function showToast(message, type) { // Check for existing toast system if (typeof window.Toast !== 'undefined') { window.Toast.show(message, type); } else if (typeof window.showNotification !== 'undefined') { window.showNotification(message, type); } else { // Fallback to a simple toast const toast = document.createElement('div'); toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-opacity duration-300 ${type === 'error' ? 'bg-red-600 text-white' : 'bg-gray-800 text-white dark:bg-gray-700'}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('opacity-0'); setTimeout(() => toast.remove(), 300); }, 3000); } } </script> <!-- Report Modal --> <div id="report-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99999; align-items: center; justify-content: center;" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <!-- Background overlay --> <div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5);" onclick="closeReportModal()"></div> <!-- Modal panel --> <div style="position: relative; z-index: 100000; background: white; border-radius: 8px; max-width: 500px; width: 90%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);" class="dark:bg-gray-800"> <form id="report-form" onsubmit="submitReport(event)"> <div class="px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="sm:flex sm:items-start"> <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10"> <svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> </svg> </div> <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1"> <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title"> Report Content </h3> <div class="mt-4"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> Reason for reporting </label> <select name="reason" id="report-reason" required class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500"> <option value="">Select a reason...</option> <option value="spam">Spam or advertising</option> <option value="harassment">Harassment or abuse</option> <option value="inappropriate">Inappropriate content</option> <option value="off_topic">Off-topic or irrelevant</option> <option value="misinformation">Misinformation</option> <option value="copyright">Copyright violation</option> <option value="other">Other</option> </select> </div> <div class="mt-4"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="report-comment-label"> Additional details (optional) </label> <textarea name="comment" id="report-comment" rows="3" maxlength="500" placeholder="Please provide any additional context..." class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500"></textarea> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400" id="report-comment-help">Max 500 characters</p> </div> </div> </div> </div> <div class="bg-gray-50 dark:bg-gray-700/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"> <button type="submit" id="report-submit-btn" class="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"> Submit Report </button> <button type="button" onclick="closeReportModal()" class="mt-3 w-full inline-flex justify-center rounded-lg border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:mt-0 sm:w-auto sm:text-sm"> Cancel </button> </div> <!-- Hidden fields for content identification --> <input type="hidden" id="report-content-type" name="content_type" value=""> <input type="hidden" id="report-content-id" name="content_id" value=""> </form> </div> </div> </div> <script> let reportModalOpen = false; // Must mirror ContentReport.DETAIL_REQUIRED_REASONS / MIN_DETAIL_LENGTH. // These reasons are meaningless to a moderator on their own, so the reporter // has to explain what happened. The server enforces this too. const REPORT_DETAIL_REQUIRED_REASONS = ['other', 'harassment']; const REPORT_MIN_DETAIL_LENGTH = 20; function reportDetailRequired(reason) { return REPORT_DETAIL_REQUIRED_REASONS.includes(reason); } function updateReportDetailRequirement() { const reason = document.getElementById('report-reason').value; const label = document.getElementById('report-comment-label'); const help = document.getElementById('report-comment-help'); const comment = document.getElementById('report-comment'); if (reportDetailRequired(reason)) { label.textContent = 'What happened? (required)'; help.textContent = `Please give at least ${REPORT_MIN_DETAIL_LENGTH} characters so a moderator can act on this.`; comment.setAttribute('required', 'required'); comment.placeholder = 'Describe what this user did, and where it happened...'; } else { label.textContent = 'Additional details (optional)'; help.textContent = 'Max 500 characters'; comment.removeAttribute('required'); comment.placeholder = 'Please provide any additional context...'; } } function openReportModal(contentType, contentId) { const modal = document.getElementById('report-modal'); if (!modal) { console.error('Report modal element not found!'); return; } document.getElementById('report-content-type').value = contentType; document.getElementById('report-content-id').value = contentId; document.getElementById('report-reason').value = ''; document.getElementById('report-comment').value = ''; document.getElementById('modal-title').textContent = contentType === 'user' ? 'Report User' : 'Report Content'; updateReportDetailRequirement(); modal.style.display = 'flex'; reportModalOpen = true; } function closeReportModal() { const modal = document.getElementById('report-modal'); modal.style.display = 'none'; reportModalOpen = false; } function submitReport(event) { event.preventDefault(); const contentType = document.getElementById('report-content-type').value; const contentId = document.getElementById('report-content-id').value; const reason = document.getElementById('report-reason').value; const comment = document.getElementById('report-comment').value; const submitBtn = document.getElementById('report-submit-btn'); if (!reason) { showToast('Please select a reason for reporting', 'error'); return; } if (reportDetailRequired(reason) && comment.trim().length < REPORT_MIN_DETAIL_LENGTH) { showToast( `Please describe what happened (at least ${REPORT_MIN_DETAIL_LENGTH} characters) for this reason.`, 'error' ); return; } submitBtn.disabled = true; submitBtn.textContent = 'Submitting...'; const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.querySelector('meta[name="csrf-token"]')?.content; fetch(`/api/content/${contentType}/${contentId}/report/`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', }, body: JSON.stringify({ reason, comment }) }) .then(response => response.json()) .then(data => { if (data.reported) { closeReportModal(); showToast(data.message, 'success'); } else { // Keep the modal open on a validation error so the reporter can // fix their submission instead of losing what they typed. showToast(data.error || data.message || 'Failed to submit report', 'error'); if (!data.error) { closeReportModal(); } } }) .catch(err => { console.error('Report error:', err); showToast('Failed to submit report', 'error'); }) .finally(() => { submitBtn.disabled = false; submitBtn.textContent = 'Submit Report'; }); } document.addEventListener('DOMContentLoaded', function() { const reasonSelect = document.getElementById('report-reason'); if (reasonSelect) { reasonSelect.addEventListener('change', updateReportDetailRequirement); } }); // Close on escape key document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && reportModalOpen) { closeReportModal(); } }); </script> <script> (function() { const MAX_QUOTE_HEIGHT = 150; // pixels before collapse function isEmptyNode(node) { if (node.nodeType === Node.TEXT_NODE) { return !node.textContent.replace(/\u00a0/g, ' ').trim(); } if (node.nodeType !== Node.ELEMENT_NODE) return true; if (node.tagName === 'BR') return true; const text = (node.textContent || '').replace(/\u00a0/g, ' ').trim(); if (text) return false; if (node.querySelector && node.querySelector('img, video, iframe, svg, audio')) return false; return true; } function trimTrailingEmpty(el) { while (el.lastChild && isEmptyNode(el.lastChild)) { el.removeChild(el.lastChild); } } function initCollapsibleQuotes() { document.querySelectorAll('.prose blockquote, .post-content blockquote').forEach(quote => { // Skip if already processed if (quote.dataset.collapsible) return; // Skip blockquotes inside editors (TipTap/ProseMirror) to avoid DOM mutation loops if (quote.closest('[contenteditable], .ProseMirror, .gamedev-editor-content, [data-tiptap], [data-tiptap-inline]')) return; quote.dataset.collapsible = 'true'; // Strip trailing empty paragraphs / <br> / whitespace so height reflects real content trimTrailingEmpty(quote); // Measure actual height const originalHeight = quote.scrollHeight; if (originalHeight > MAX_QUOTE_HEIGHT) { // Add collapsed state quote.style.maxHeight = MAX_QUOTE_HEIGHT + 'px'; quote.style.overflow = 'hidden'; quote.classList.add('quote-collapsed'); // Create expand button const expandBtn = document.createElement('button'); expandBtn.type = 'button'; expandBtn.className = 'quote-expand-btn mt-2 flex w-full items-center justify-center py-2 text-xs font-medium text-sky-600 dark:text-sky-400 hover:text-sky-700 dark:hover:text-sky-300 transition-colors'; expandBtn.innerHTML = ` <span class="flex items-center justify-center gap-1"> <svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> </svg> <span class="expand-text">Show more</span> </span> `; expandBtn.addEventListener('click', function(e) { e.preventDefault(); const isCollapsed = quote.classList.contains('quote-collapsed'); if (isCollapsed) { quote.style.maxHeight = originalHeight + 'px'; quote.classList.remove('quote-collapsed'); quote.classList.add('quote-expanded'); expandBtn.querySelector('.expand-text').textContent = 'Show less'; expandBtn.querySelector('.expand-icon').style.transform = 'rotate(180deg)'; } else { quote.style.maxHeight = MAX_QUOTE_HEIGHT + 'px'; quote.classList.add('quote-collapsed'); quote.classList.remove('quote-expanded'); expandBtn.querySelector('.expand-text').textContent = 'Show more'; expandBtn.querySelector('.expand-icon').style.transform = 'rotate(0deg)'; } }); // Add wrapper for positioning const wrapper = document.createElement('div'); wrapper.className = 'quote-wrapper flex flex-col'; quote.parentNode.insertBefore(wrapper, quote); wrapper.appendChild(quote); wrapper.appendChild(expandBtn); } }); } // Run on page load document.addEventListener('DOMContentLoaded', initCollapsibleQuotes); // Re-run after HTMX swaps only for post content areas (not sidebar/nav updates) document.body.addEventListener('htmx:afterSwap', function(e) { var target = e.detail.target || e.target; if (target instanceof Element && (target.id === 'posts-list' || target.closest('#posts-list') || target.classList.contains('post-content'))) { initCollapsibleQuotes(); } }); })(); </script> <style> .quote-wrapper { position: relative; } .quote-wrapper blockquote { transition: max-height 0.3s ease-out; } .quote-wrapper blockquote.quote-collapsed { margin-bottom: 0; } .quote-expand-btn { cursor: pointer; border: none; background: transparent; } .quote-expand-btn .expand-icon { transition: transform 0.2s ease; } </style> </main> </div> <!-- Tailwind Footer - Full width --> <footer class="bg-gray-900 text-gray-300 w-full"> <div class="site-shell py-12"> <div class="grid grid-cols-1 md:grid-cols-4 gap-8"> <!-- Brand --> <div class="md:col-span-1"> <a href="/" class="text-2xl font-bold text-white">GameDev<span class="text-sky-500">.net</span></a> <p class="mt-3 text-sm text-gray-400">The game development community since 1999. Learn, share, and connect with developers worldwide.</p> </div> <!-- Quick Links --> <div> <h4 class="font-semibold text-white mb-4">Community</h4> <ul class="space-y-2 text-sm"> <li><a href="/forums/" class="hover:text-white transition">Forums</a></li> <li><a href="/blogs/" class="hover:text-white transition">Blogs</a></li> <li><a href="/projects/" class="hover:text-white transition">Projects</a></li> <li><a href="/portfolios/" class="hover:text-white transition">Portfolios</a></li> <li><a href="/badges/" class="hover:text-white transition">Badges</a></li> </ul> </div> <!-- Resources --> <div> <h4 class="font-semibold text-white mb-4">Resources</h4> <ul class="space-y-2 text-sm"> <li><a href="/tutorials/" class="hover:text-white transition">Tutorials</a></li> <li><a href="/news/" class="hover:text-white transition">News</a></li> <li><a href="/start-game-development/" class="hover:text-white transition">How to Make a Game</a></li> <li><a href="/guide/" class="hover:text-white transition">Getting Started Guide</a></li> <li><a href="/guilds/" class="hover:text-white transition">Topics & Guilds</a></li> <li><a href="/pro/" class="hover:text-white transition">GameDev Pro</a></li> <li><a href="/developers/mcp/" class="hover:text-white transition">Developers</a></li> </ul> </div> <!-- Legal --> <div> <h4 class="font-semibold text-white mb-4">About & Legal</h4> <ul class="space-y-2 text-sm"> <li><a href="/guidelines/" class="hover:text-white transition">Community Guidelines</a></li> <li><a href="/terms/" class="hover:text-white transition">Terms of Service</a></li> <li><a href="/privacy/" class="hover:text-white transition">Privacy Policy</a></li> <li><a href="/licensing/" class="hover:text-white transition">Content Licensing</a></li> <li><a href="/contact/" class="hover:text-white transition">Contact Us</a></li> <li><a href="/about/" class="hover:text-white transition">About</a></li> <li><a href="/changelog/" class="hover:text-white transition">Changelog</a></li> </ul> </div> </div> <!-- Social Links --> <div class="border-t border-gray-800 mt-10 pt-6"> <div class="flex flex-col md:flex-row justify-between items-center gap-4"> <p class="text-sm text-gray-500">&copy; 1999-2026 GameDev.net, LLC. All rights reserved.</p> <div class="flex items-center gap-3"> <a href="https://discord.gg/tgEGVNy" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="Discord"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg> </a> <a href="https://twitter.com/gdevnet" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="X / Twitter"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg> </a> <a href="https://www.facebook.com/GameDev.net/" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="Facebook"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> </a> <a href="https://instagram.com/gdevnet" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="Instagram"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg> </a> <a href="https://threads.net/gdevnet" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="Threads"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.042 1.259-1.233 1.885-2.742 1.86-4.486-.016-1.146-.348-2.143-.988-2.962-.587-.752-1.357-1.18-2.254-1.205a2.559 2.559 0 0 0-.793.062c.135.478.2.976.188 1.48-.046 2.02-1.2 3.51-3.01 3.885a4.066 4.066 0 0 1-.88.098c-1.2 0-2.255-.504-2.975-1.418-.6-.762-.907-1.726-.907-2.852 0-1.893.827-3.544 2.268-4.522a5.126 5.126 0 0 1 2.91-.901c.63.006 1.237.1 1.816.282.26-.586.652-1.076 1.164-1.44a3.487 3.487 0 0 1 2-.614h.076c1.478.025 2.789.698 3.692 1.893 1.007 1.333 1.52 3.092 1.527 5.238.026 2.387-.87 4.505-2.594 6.125C17.904 23.14 15.413 23.98 12.186 24zM11.29 11.083c-.885.386-1.465 1.348-1.465 2.56 0 1.097.528 1.93 1.406 2.222.216.072.445.108.681.108.985 0 1.804-.66 1.91-1.553a2.84 2.84 0 0 0-.037-.793 3.86 3.86 0 0 0-.18-.602 3.592 3.592 0 0 0-2.315-1.942z"/></svg> </a> <a href="https://reddit.com/r/gamedevnet" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-white transition" title="Reddit"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg> </a> <span class="w-px h-4 bg-gray-700"></span> <a href="/rss/" target="_blank" class="text-gray-400 hover:text-white transition" title="RSS Feed"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248-1.796 0-3.252-1.454-3.252-3.248 0-1.794 1.456-3.248 3.252-3.248 1.795 0 3.251 1.454 3.251 3.248zm-6.503-12.572v4.811c6.05.062 10.96 4.966 11.022 11.009h4.817c-.062-8.742-7.115-15.793-15.839-15.82zm0-8.18v4.819c12.951.115 23.412 10.617 23.5 23.581h4.5c-.115-15.452-12.652-27.988-28-28.4z"/></svg> </a> <span class="w-px h-4 bg-gray-700"></span> <a href="#" class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd"/></svg> Back to Top </a> </div> </div> </div> </div> </footer> <script>(function(){var darkIcon=document.getElementById('theme-toggle-dark-icon');var lightIcon=document.getElementById('theme-toggle-light-icon');var systemIcon=document.getElementById('theme-toggle-system-icon');var btn=document.getElementById('theme-toggle');var stored=localStorage.getItem('color-theme');function hideAllIcons(){darkIcon.classList.add('hidden');lightIcon.classList.add('hidden');systemIcon.classList.add('hidden');} function showIcon(){hideAllIcons();if(!stored){systemIcon.classList.remove('hidden');btn.title='Theme: Auto (system)';}else if(stored==='dark'){lightIcon.classList.remove('hidden');btn.title='Theme: Dark';}else{darkIcon.classList.remove('hidden');btn.title='Theme: Light';}} function applyTheme(){if(stored==='dark'||(!stored&&window.matchMedia('(prefers-color-scheme: dark)').matches)){document.documentElement.classList.add('dark');}else{document.documentElement.classList.remove('dark');}} showIcon();btn.addEventListener('click',function(){if(!stored){stored='light';localStorage.setItem('color-theme','light');}else if(stored==='light'){stored='dark';localStorage.setItem('color-theme','dark');}else{stored=null;localStorage.removeItem('color-theme');} applyTheme();showIcon();if(!stored){window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change',function handler(e){if(localStorage.getItem('color-theme')){e.target.removeEventListener('change',handler);return;} applyTheme();});}});})();;</script> <script src="https://unpkg.com/htmx.org@2.0.4"></script> <script> // Global HTMX CSRF — ensures all HTMX POST/PUT/DELETE requests include Django's CSRF token document.body.addEventListener('htmx:configRequest', function(evt) { var token = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.cookie.split('; ').find(function(r){return r.startsWith('csrftoken=')})?.split('=')[1] || ''; if (token) evt.detail.headers['X-CSRFToken'] = token; }); </script> <script src="https://cdn.jsdelivr.net/npm/fslightbox@3.4.2/index.min.js"></script> <script src="/static/gamedevnext/js/content-lightbox.4ca339e55408.js"></script> <script> document.addEventListener('DOMContentLoaded', function() { // Find all oembed elements and convert them to actual embeds document.querySelectorAll('figure.media > oembed, oembed').forEach(function(item) { var url = item.getAttribute('url'); if (!url) return; fetch('/embed/get/?url=' + encodeURIComponent(url), { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) .then(response => response.json()) .then(data => { if (data.preview) { // Replace the oembed element or its parent figure var parent = item.closest('figure.media') || item.parentElement; if (parent) { parent.replaceWith(data.preview); } else { item.replaceWith(data.preview); } } }) .catch(err => console.warn('Failed to load embed:', url, err)); }); }); </script> <script src="/static/gamedevnext/js/user-tooltip.08c872581492.js"></script> <script src="/static/gamedevnext/js/shader-tiles.3e9477d364a5.js" defer></script> <script src="/static/bundles/runtime-5781e36a.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-02e12e81-2ba9b67b.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-839f78ca-54605f01.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-631f0d8c-7e852c16.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-da055998-064d2c61.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-f0157c14-f6130f88.js" ></script> <script src="/static/bundles/tiptap-gdx-vendors-d2d18b07-0d4f8bbf.js" ></script> <script src="/static/bundles/7515-625c095f.js" ></script> <script src="/static/bundles/8339-b65c7b02.js" ></script> <script src="/static/bundles/803-6483b301.js" ></script> <script src="/static/bundles/2845-52745896.js" ></script> <script src="/static/bundles/8262-7d245816.js" ></script> <script src="/static/bundles/914-e576fc61.js" ></script> <script src="/static/bundles/3801-9b9f5dd6.js" ></script> <link href="/static/bundles/message_editor-d1be09fe.css" rel="stylesheet" /> <script src="/static/bundles/message_editor-c58f8947.js" ></script> <!-- Beta Feedback Modal --> <div id="beta-feedback-modal" class="fixed inset-0 z-[100] hidden" role="dialog" aria-modal="true" aria-labelledby="beta-feedback-title" onclick="if(event.target === this) closeBetaFeedback()"> <div class="fixed inset-0 bg-black/50 backdrop-blur-sm" aria-hidden="true"></div> <div class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md"> <div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 m-4" id="beta-feedback-dialog"> <div class="flex items-center justify-between mb-4"> <h3 id="beta-feedback-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2"> <svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/> </svg> Beta Feedback </h3> <button onclick="closeBetaFeedback()" aria-label="Close feedback dialog" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> </svg> </button> </div> <div id="beta-feedback-content"> <form id="beta-feedback-form" hx-post="/api/beta/feedback/" hx-target="#beta-feedback-content" hx-swap="innerHTML" hx-indicator="#feedback-spinner"> <input type="hidden" name="csrfmiddlewaretoken" value="HzVVq2BYt4lgC65futfkGh03QLCimeTN6MgJHQ3Tj9L38ddRORFS1mKjy0HCGNC9"> <input type="hidden" name="page_url" id="feedback-page-url"> <input type="hidden" name="browser_info" id="feedback-browser-info"> <div class="mb-4"> <label for="feedback-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label> <select name="feedback_type" id="feedback-type" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:outline-none"> <option value="bug">🐛 Bug Report</option> <option value="suggestion">💡 Suggestion</option> <option value="question">❓ Question</option> <option value="other">📝 Other</option> </select> </div> <div class="mb-4"> <label for="feedback-description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Description <span class="text-gray-400 font-normal">(max 5000 chars)</span> </label> <textarea name="description" id="feedback-description" rows="4" required maxlength="5000" placeholder="Describe the issue or feedback..." class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 placeholder-gray-400 resize-none focus:outline-none"></textarea> </div> <div class="mb-4"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Screenshot <span class="text-gray-400 font-normal">(optional)</span> </label> <input type="hidden" name="screenshot_url" id="feedback-screenshot-url"> <div id="screenshot-preview" class="hidden mb-2"> <div class="relative inline-block"> <img id="screenshot-img" class="max-h-32 rounded border border-gray-300 dark:border-gray-600"> <button type="button" onclick="clearScreenshot()" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> </svg> </button> </div> </div> <div id="screenshot-buttons" class="flex gap-2"> <button type="button" onclick="captureScreenshot()" class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-1.5"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/> </svg> Capture Screen </button> <label class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-1.5 cursor-pointer"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/> </svg> Upload Image <input type="file" accept="image/png,image/jpeg,image/webp" class="hidden" onchange="uploadScreenshot(this)"> </label> </div> <p id="screenshot-status" class="text-xs text-gray-500 mt-1 hidden"></p> </div> <div class="flex justify-end gap-2"> <button type="button" onclick="closeBetaFeedback()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"> Cancel </button> <button type="submit" class="px-4 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"> <span id="feedback-spinner" class="htmx-indicator"> <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> </span> Submit Feedback </button> </div> </form> </div> </div> </div> </div> <script> (function() { let previousActiveElement = null; const feedbackContentEl = document.getElementById('beta-feedback-content'); const originalFeedbackHTML = feedbackContentEl ? feedbackContentEl.innerHTML : null; window.openBetaFeedback = function() { previousActiveElement = document.activeElement; // Restore form HTML first if it was replaced by HTMX submission if (feedbackContentEl && originalFeedbackHTML && !document.getElementById('feedback-page-url')) { feedbackContentEl.innerHTML = originalFeedbackHTML; if (typeof htmx !== 'undefined') htmx.process(feedbackContentEl); } const pageUrlEl = document.getElementById('feedback-page-url'); const browserInfoEl = document.getElementById('feedback-browser-info'); if (pageUrlEl) pageUrlEl.value = window.location.href; if (browserInfoEl) browserInfoEl.value = navigator.userAgent; const modal = document.getElementById('beta-feedback-modal'); if (!modal) return; modal.classList.remove('hidden'); document.body.classList.add('overflow-hidden'); // Focus first input after modal opens setTimeout(() => { const ft = document.getElementById('feedback-type'); if (ft) ft.focus(); }, 50); }; window.closeBetaFeedback = function() { const modal = document.getElementById('beta-feedback-modal'); modal.classList.add('hidden'); document.body.classList.remove('overflow-hidden'); // Return focus to trigger element if (previousActiveElement) previousActiveElement.focus(); // Restore original form HTML after a delay (HTMX replaces it on submit) setTimeout(() => { if (feedbackContentEl && originalFeedbackHTML) { feedbackContentEl.innerHTML = originalFeedbackHTML; htmx.process(feedbackContentEl); } }, 300); }; // Close on Escape key (only when modal is open) document.addEventListener('keydown', function(e) { const modal = document.getElementById('beta-feedback-modal'); if (e.key === 'Escape' && modal && !modal.classList.contains('hidden')) { closeBetaFeedback(); } }); // Focus trap for modal document.addEventListener('keydown', function(e) { const modal = document.getElementById('beta-feedback-modal'); if (!modal || modal.classList.contains('hidden') || e.key !== 'Tab') return; const dialog = document.getElementById('beta-feedback-dialog'); const focusable = dialog.querySelectorAll('button, select, textarea, [tabindex]:not([tabindex="-1"])'); const first = focusable[0], last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); // Screenshot functions window.captureScreenshot = async function() { const modal = document.getElementById('beta-feedback-modal'); const status = document.getElementById('screenshot-status'); // Hide modal so the screenshot shows the actual page if (modal) modal.style.visibility = 'hidden'; try { // Use Screen Capture API (browser shows picker while modal is hidden) const stream = await navigator.mediaDevices.getDisplayMedia({ video: { mediaSource: 'screen' } }); // Restore modal immediately after user picks a source if (modal) modal.style.visibility = ''; status.classList.remove('hidden'); status.textContent = 'Capturing...'; // Capture frame from stream const video = document.createElement('video'); video.srcObject = stream; await video.play(); const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); // Stop stream stream.getTracks().forEach(track => track.stop()); // Convert to blob and upload status.textContent = 'Uploading screenshot...'; canvas.toBlob(async (blob) => { await uploadScreenshotBlob(blob); }, 'image/png'); } catch (err) { // Restore modal on error too if (modal) modal.style.visibility = ''; console.error('Screenshot capture failed:', err); status.classList.remove('hidden'); status.textContent = err.name === 'NotAllowedError' ? 'Screen capture was cancelled' : 'Failed to capture screenshot'; setTimeout(() => status.classList.add('hidden'), 3000); } }; window.uploadScreenshot = function(input) { if (!input.files || !input.files[0]) return; const file = input.files[0]; const status = document.getElementById('screenshot-status'); status.classList.remove('hidden'); status.textContent = 'Uploading...'; uploadScreenshotBlob(file); }; async function uploadScreenshotBlob(blob) { const status = document.getElementById('screenshot-status'); const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''; const formData = new FormData(); formData.append('screenshot', blob, 'screenshot.png'); try { const response = await fetch('/api/beta/feedback/screenshot/', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }, body: formData }); const data = await response.json(); if (data.status === 'ok') { document.getElementById('feedback-screenshot-url').value = data.url; document.getElementById('screenshot-img').src = data.url; document.getElementById('screenshot-preview').classList.remove('hidden'); document.getElementById('screenshot-buttons').classList.add('hidden'); status.classList.add('hidden'); } else { status.textContent = data.message || 'Upload failed'; setTimeout(() => status.classList.add('hidden'), 3000); } } catch (err) { console.error('Screenshot upload failed:', err); status.textContent = 'Upload failed'; setTimeout(() => status.classList.add('hidden'), 3000); } } window.clearScreenshot = function() { document.getElementById('feedback-screenshot-url').value = ''; document.getElementById('screenshot-img').src = ''; document.getElementById('screenshot-preview').classList.add('hidden'); document.getElementById('screenshot-buttons').classList.remove('hidden'); }; })(); </script> <!-- Error Tracking for All --> <script> (function() { const loggedErrors = new Set(); // Dedupe within session const MAX_ERRORS = 5; // Max errors to log per page load let errorCount = 0; // Third-party scripts to ignore (ads, analytics, etc.) const IGNORED_PATTERNS = [ /venatus/i, /vntsm\.com/i, /googletag/i, /googletagmanager/i, /google-analytics/i, /doubleclick/i, /securepubads/i, /googlesyndication/i, /fundingchoicesmessages/i, /googlefcPresent/i, /adsystem/i, /adsbygoogle/i, /pagead/i, /prebid/i, /amazon-adsystem/i, /facebook\.net/i, /connect\.facebook/i, /twitter\.com\/widgets/i, /platform\.twitter/i, /disqus/i, /cdn\.segment/i, /hotjar/i, /clarity\.ms/i, /newrelic/i, /sentry/i, /Script error\.?$/i, // Generic cross-origin script error ]; function isIgnored(/* ...strings */) { for (var i = 0; i < arguments.length; i++) { var s = arguments[i]; if (s && IGNORED_PATTERNS.some(function(p) { return p.test(s); })) return true; } return false; } function logError(source, type, message, stack, extra) { // Limit errors per page to prevent flooding if (errorCount >= MAX_ERRORS) return; // Skip third-party/ad network errors — check message, stack, AND source filename var filename = extra && extra.filename || ''; if (isIgnored(message, stack, filename)) return; // Dedupe by fingerprint (type + first 100 chars of message) const fingerprint = type + ':' + (message || '').slice(0, 100); if (loggedErrors.has(fingerprint)) return; loggedErrors.add(fingerprint); errorCount++; fetch('/api/errors/log/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ source: source, type: type, message: message, stack: stack || '', url: window.location.href, userAgent: navigator.userAgent, ...extra }) }).catch(() => {}); // Silently fail } // Capture unhandled JS errors window.addEventListener('error', function(event) { var stack = event.error?.stack || ''; // Build a synthetic location string when the Error object has no stack // (common with cross-origin scripts or older browsers) if (!stack && event.filename) { stack = 'at ' + event.filename + ':' + (event.lineno || '?') + ':' + (event.colno || '?'); } logError( 'client', event.error?.name || 'Error', event.message, stack, { filename: event.filename || '', lineno: event.lineno || 0, colno: event.colno || 0 } ); }); // Capture unhandled promise rejections window.addEventListener('unhandledrejection', function(event) { var reason = event.reason; // Skip undefined/null rejections with no stack — almost always // third-party scripts (ad SDKs, consent managers) that reject // promises without providing a reason. Not actionable. if (reason == null && !(reason instanceof Error)) return; var message = reason?.message || String(reason); var stack = reason?.stack || ''; var type = 'UnhandledPromise'; // If reason is an Error subclass, use its name for better triage if (reason instanceof Error && reason.constructor?.name) { type = 'UnhandledPromise:' + reason.constructor.name; } // If reason isn't an Error, add type info for diagnostics if (reason && !(reason instanceof Error) && typeof reason !== 'string') { message = '[' + typeof reason + '] ' + message; } logError('client', type, message, stack); }); // Decorative fragments loaded lazily after render. Their failure is // invisible to the user, and 403/429 on them comes from the edge // (WAF/bot protection) or rate limiting rather than an app fault. var OPTIONAL_FRAGMENT_PATTERNS = [ /^\/forums\/topic\/\d+\/related\/$/, /^\/forums\/api\/popular-topics\/$/, /^\/forums\/api\/tab\//, ]; var EDGE_BLOCK_STATUSES = [403, 429]; function isOptionalFragmentBlock(path, status) { if (EDGE_BLOCK_STATUSES.indexOf(status) === -1) return false; return OPTIONAL_FRAGMENT_PATTERNS.some(function(p) { return p.test(path); }); } // Capture HTMX errors document.body.addEventListener('htmx:responseError', function(event) { var reqPath = event.detail.pathInfo?.requestPath || 'unknown'; if (isOptionalFragmentBlock(reqPath, event.detail.xhr.status)) return; logError('htmx', 'ResponseError', 'HTTP ' + event.detail.xhr.status + ' on ' + reqPath, '', { requestUrl: reqPath, triggerId: event.detail.elt?.id }); }); document.body.addEventListener('htmx:sendError', function(event) { var reqUrl = event.detail.pathInfo?.requestPath || event.detail.requestConfig?.path || event.detail.elt?.getAttribute('hx-get') || event.detail.elt?.getAttribute('hx-post') || 'unknown'; logError('htmx', 'NetworkError', 'Request failed', '', { requestUrl: reqUrl, triggerId: event.detail.elt?.id }); }); })(); </script> <!-- Syntax Highlighting for code blocks (site-wide) --> <link rel="stylesheet" id="hljs-theme-light" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"> <link rel="stylesheet" id="hljs-theme-dark" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <script> (function() { function syncHljsTheme() { var dark = document.documentElement.classList.contains('dark'); var light = document.getElementById('hljs-theme-light'); var darkEl = document.getElementById('hljs-theme-dark'); if (light) light.disabled = dark; if (darkEl) darkEl.disabled = !dark; } syncHljsTheme(); new MutationObserver(syncHljsTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); })(); </script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <!-- Game dev languages (C++, JS, Python, Java, JSON, XML, Bash, CSS are in core bundle) --> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/glsl.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/rust.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/csharp.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/lua.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/swift.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/kotlin.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/objectivec.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cmake.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/wasm.min.js"></script> <script> document.addEventListener("DOMContentLoaded", function() { document.querySelectorAll('pre code').forEach((block) => { block.classList.remove('language-plaintext'); }); if (typeof hljs !== 'undefined') { hljs.highlightAll(); } }); </script> <script src="/static/gamedevnext/js/code-decorate.237c7d751ec8.js" defer></script> <!-- Global Toast Notification System --> <div id="toast-container" class="fixed bottom-16 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div> <style> .toast { pointer-events: auto; transform: translateX(100%); opacity: 0; transition: transform 300ms ease-out, opacity 300ms ease-out; } .toast.show { transform: translateX(0); opacity: 1; } .toast.hiding { transform: translateX(100%); opacity: 0; } </style> <script> (function() { const toastColors = { 'success': 'bg-green-600 text-white', 'error': 'bg-red-600 text-white', 'warning': 'bg-amber-500 text-white', 'info': 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900', 'debug': 'bg-gray-600 text-white' }; const toastIcons = { 'success': '<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>', 'error': '<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>', 'warning': '<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>', 'info': '<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' }; /** * Show a toast notification * @param {string} message - The message to display * @param {string} type - Type: 'success', 'error', 'warning', 'info' * @param {object} options - Optional settings: duration, action, actionLabel, isMessageNotification */ window.showToast = function(message, type = 'info', options = {}) { const container = document.getElementById('toast-container'); if (!container) return; // Suppress message notifications when on the messages page if (options.isMessageNotification && window.isMessagesPage) return; const duration = options.duration || (type === 'error' ? 0 : (type === 'warning' ? 6000 : 4000)); const action = options.action; const actionLabel = options.actionLabel || 'Undo'; const toast = document.createElement('div'); toast.className = `toast ${toastColors[type] || toastColors.info} px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-3 max-w-sm`; let html = `${toastIcons[type] || toastIcons.info}<span class="flex-1">${message}</span>`; if (action) { html += `<button class="toast-action font-medium underline underline-offset-2 hover:no-underline">${actionLabel}</button>`; } html += `<button class="toast-close opacity-70 hover:opacity-100 transition-opacity" aria-label="Dismiss"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> </button>`; toast.innerHTML = html; container.appendChild(toast); // Trigger reflow for animation toast.offsetHeight; toast.classList.add('show'); const dismiss = () => { toast.classList.add('hiding'); toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }; // Close button toast.querySelector('.toast-close').addEventListener('click', dismiss); // Action button if (action) { toast.querySelector('.toast-action').addEventListener('click', () => { action(); dismiss(); }); } if (duration > 0) { setTimeout(dismiss, duration); } return { dismiss }; }; /** * Show a message notification toast with quick reply capability * @param {object} data - Message notification data * - senderName: Name of the sender * - senderAvatar: Avatar URL (optional) * - preview: Message preview text * - conversationId: Conversation ID for reply */ window.showMessageToast = function(data) { const container = document.getElementById('toast-container'); if (!container || window.isMessagesPage) return; const toast = document.createElement('div'); toast.className = 'toast bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3 rounded-lg shadow-lg text-sm max-w-sm'; const avatarHtml = data.senderAvatar ? `<img src="${data.senderAvatar}" alt="${data.senderName}" class="w-10 h-10 rounded-full object-cover">` : `<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-gray-500 dark:text-gray-400 font-medium">${(data.senderName || '?').charAt(0).toUpperCase()}</div>`; toast.innerHTML = ` <div class="flex items-start gap-3 mb-2"> ${avatarHtml} <div class="flex-1 min-w-0"> <p class="font-medium text-gray-900 dark:text-white truncate">${data.senderName || 'Unknown'}</p> <p class="text-gray-600 dark:text-gray-400 text-xs truncate">${data.preview || 'New message'}</p> </div> <button class="toast-close text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" aria-label="Dismiss"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> </button> </div> <form class="quick-reply-form flex gap-2"> <input type="text" class="flex-1 px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-700 border-0 rounded-lg focus:ring-2 focus:ring-sky-500 text-gray-900 dark:text-white placeholder-gray-400" placeholder="Quick reply..." autocomplete="off"> <button type="submit" class="px-3 py-1.5 bg-sky-600 text-white text-xs rounded-lg hover:bg-sky-700 disabled:opacity-50"> Send </button> </form> <div class="flex gap-2 mt-2"> <a href="/messages/${data.conversationId}/" class="text-xs text-sky-600 dark:text-sky-400 hover:underline">Open conversation</a> </div> `; container.appendChild(toast); toast.offsetHeight; toast.classList.add('show'); const dismiss = () => { toast.classList.add('hiding'); toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }; toast.querySelector('.toast-close').addEventListener('click', dismiss); // Handle quick reply form const form = toast.querySelector('.quick-reply-form'); const input = form.querySelector('input'); const submitBtn = form.querySelector('button[type="submit"]'); form.addEventListener('submit', async (e) => { e.preventDefault(); const content = input.value.trim(); if (!content) return; submitBtn.disabled = true; submitBtn.textContent = '...'; try { const formData = new FormData(); formData.append('content', content); const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || ''; const response = await fetch(`/messages/api/${data.conversationId}/send/`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, body: formData }); if (response.ok) { window.showToast('Reply sent!', 'success'); dismiss(); } else { const errData = await response.json(); window.showToast(errData.error || 'Failed to send', 'error'); submitBtn.disabled = false; submitBtn.textContent = 'Send'; } } catch (err) { window.showToast('Failed to send reply', 'error'); submitBtn.disabled = false; submitBtn.textContent = 'Send'; } }); // Auto-dismiss after 15 seconds (longer for message notifications) setTimeout(dismiss, 15000); return { dismiss }; }; // ===================================== // Global Emoji Conversion Utility // ===================================== window.emojiUtils = (function() { // Text emoticons → emoji const TEXT_EMOTICONS = { ':-)': '😊', ':)': '😊', '=)': '😊', ':-D': '😄', ':D': '😄', '=D': '😄', ':-P': '😛', ':P': '😛', ':p': '😛', ':-p': '😛', ';-)': '😉', ';)': '😉', ':-(': '😢', ':(': '😢', '=(': '😢', ":'(": '😭', ':-O': '😮', ':O': '😮', ':o': '😮', ':-o': '😮', 'O_O': '😳', 'o_o': '😳', ':-/': '😕', ':/': '😕', ':-\\': '😕', ':\\': '😕', ':-|': '😐', ':|': '😐', 'B-)': '😎', 'B)': '😎', '8-)': '😎', '8)': '😎', '<3': '❤️', '</3': '💔', ':-*': '😘', ':*': '😘', '^_^': '😊', '^-^': '😊', '>_<': '😣', '>.<': '😣', '-_-': '😑', 'XD': '😆', 'xD': '😆', ':3': '😺', ':-3': '😺', }; // Common shortcodes (subset for client-side) const SHORTCODES = { ':smile:': '😄', ':grin:': '😁', ':joy:': '😂', ':wink:': '😉', ':blush:': '😊', ':heart_eyes:': '😍', ':sunglasses:': '😎', ':thinking:': '🤔', ':cry:': '😢', ':sob:': '😭', ':thumbsup:': '👍', ':thumbs_up:': '👍', ':+1:': '👍', ':thumbsdown:': '👎', ':-1:': '👎', ':clap:': '👏', ':wave:': '👋', ':pray:': '🙏', ':muscle:': '💪', ':heart:': '❤️', ':fire:': '🔥', ':100:': '💯', ':star:': '⭐', ':sparkles:': '✨', ':zap:': '⚡', ':boom:': '💥', ':game:': '🎮', ':gamepad:': '🎮', ':trophy:': '🏆', ':crown:': '👑', ':gem:': '💎', ':rocket:': '🚀', ':sword:': '⚔️', ':computer:': '💻', ':bug:': '🐛', ':bulb:': '💡', ':gear:': '⚙️', ':check:': '✅', ':x:': '❌', ':warning:': '⚠️', ':eyes:': '👀', ':tada:': '🎉', ':party:': '🎉', ':coffee:': '☕', }; // Build regex patterns (sorted by length, longest first) const emoticonKeys = Object.keys(TEXT_EMOTICONS).sort((a, b) => b.length - a.length); const emoticonPattern = new RegExp( '(?<![a-zA-Z0-9/])(' + emoticonKeys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')(?![a-zA-Z0-9])', 'g' ); const shortcodeKeys = Object.keys(SHORTCODES).sort((a, b) => b.length - a.length); const shortcodePattern = new RegExp( '(' + shortcodeKeys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'g' ); return { // Convert text emoticons like :) to emoji convertEmoticons(text) { if (!text) return text; return text.replace(emoticonPattern, (match, p1) => TEXT_EMOTICONS[p1] || match); }, // Convert shortcodes like :smile: to emoji convertShortcodes(text) { if (!text || !text.includes(':')) return text; return text.replace(shortcodePattern, (match) => SHORTCODES[match] || match); }, // Convert all emoji representations convertAll(text) { if (!text) return text; text = this.convertEmoticons(text); text = this.convertShortcodes(text); return text; }, // Get all available emoticons for reference getEmoticons() { return { ...TEXT_EMOTICONS }; }, getShortcodes() { return { ...SHORTCODES }; }, }; })(); // HTMX Integration: Listen for server-triggered toasts via HX-Trigger header // Server can send: HX-Trigger: {"showToast": {"message": "Saved!", "type": "success"}} document.body.addEventListener('showToast', function(evt) { const detail = evt.detail || {}; window.showToast(detail.message || 'Action completed', detail.type || 'info', { duration: detail.duration, action: detail.action, actionLabel: detail.actionLabel }); }); // Also listen for htmx:afterRequest for HX-Trigger headers document.body.addEventListener('htmx:afterRequest', function(evt) { const trigger = evt.detail.xhr?.getResponseHeader('HX-Trigger'); if (trigger) { try { const parsed = JSON.parse(trigger); if (parsed.showToast) { const t = parsed.showToast; window.showToast(t.message, t.type, t); } } catch (e) { // Not JSON or doesn't have showToast } } }); // Render Django messages as toasts on page load document.addEventListener('DOMContentLoaded', function() { // Moderator queue notification }); })(); </script> <style> img.img-broken { position: relative; min-height: 40px; background: #f3f4f6; border-radius: 0.375rem; } .dark img.img-broken { background: #1f2937; } img.img-broken::after { content: attr(alt); display: flex; align-items: center; justify-content: center; position: absolute; inset: 0; background: inherit; color: #9ca3af; font-size: 0.75rem; border-radius: inherit; padding: 0.5rem; text-align: center; overflow: hidden; } </style> <script> // Global broken-image fallback — runs in two stages to avoid false positives: // 1) First error: strip the Referer (no-referrer) and/or upgrade to https, // then retry. Many external hosts (Google user-content, Imgur, etc.) // 403 with a Referer but load fine without one. // 2) Still failing: mark .img-broken so the CSS ::after overlay shows. // Also re-checks once the element actually loaded successfully and clears // the broken state if it recovers (e.g. slow network). document.addEventListener('error', function(e) { var img = e.target; if (!img || img.tagName !== 'IMG') return; // Skip images inside TipTap/ProseMirror editors — they have their own retry logic if (img.closest && img.closest('.ProseMirror, [contenteditable], .resizable-image-container')) return; if (!img.getAttribute('src')) return; if (!img.dataset.retryAttempted) { img.dataset.retryAttempted = 'true'; var src = img.getAttribute('src'); var changed = false; // Drop Referer on retry if (img.getAttribute('referrerpolicy') !== 'no-referrer') { img.setAttribute('referrerpolicy', 'no-referrer'); changed = true; } // Upgrade mixed-content http:// to https:// if (src.indexOf('http://') === 0) { src = 'https://' + src.slice(7); changed = true; } // Force a reload by resetting src (cache-busted) img.src = src + (src.indexOf('?') === -1 ? '?' : '&') + '_r=' + Date.now(); return; } if (img.dataset.fallbackApplied) return; img.dataset.fallbackApplied = 'true'; img.classList.add('img-broken'); if (!img.alt) img.alt = 'Image unavailable'; }, true); // If an image eventually loads after being marked broken, clear the overlay. document.addEventListener('load', function(e) { var img = e.target; if (!img || img.tagName !== 'IMG') return; if (img.naturalWidth > 0 && img.classList.contains('img-broken')) { img.classList.remove('img-broken'); } }, true); </script> <div data-nosnippet x-data="{ show: false, readConsent() { try { return localStorage.getItem('gdx_cookie_consent'); } catch (err) { return null; } }, writeConsent(value) { try { localStorage.setItem('gdx_cookie_consent', value); return true; } catch (err) { return false; } }, init() { var stored = this.readConsent(); if (!stored) { this.show = true; } else if (stored === 'accepted') { if (window.gtag) { gtag('consent', 'update', {'analytics_storage': 'granted'}); } } else if (stored === 'rejected') { this.show = false; } else { this.show = true; } }, accept() { var persisted = this.writeConsent('accepted'); this.show = false; if (!persisted) return; if (window.gtag) { gtag('consent', 'update', {'analytics_storage': 'granted'}); } window.dispatchEvent(new Event('gdx:analytics-consent-granted')); }, reject() { this.writeConsent('rejected'); this.show = false; if (window.gtag) { gtag('consent', 'update', {'analytics_storage': 'denied'}); } window.dispatchEvent(new Event('gdx:analytics-consent-denied')); } }" x-show="show" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="translate-y-full opacity-0" x-transition:enter-end="translate-y-0 opacity-100" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-full opacity-0" x-cloak class="fixed bottom-0 inset-x-0 z-[9999]" role="dialog" aria-label="Cookie consent"> <div class="mx-auto max-w-5xl px-4 pb-4"> <div class="flex flex-col sm:flex-row items-center justify-between gap-3 rounded-xl bg-gray-900 dark:bg-gray-800 border border-gray-700 px-5 py-4 shadow-2xl text-sm text-gray-300"> <p class="text-center sm:text-left"> With your permission, GameDev.net uses analytics cookies to understand how people use the platform. You can accept analytics or continue with necessary cookies only. <a href="/privacy/" class="underline text-blue-400 hover:text-blue-300 ml-1">Learn more</a> </p> <div class="flex shrink-0 items-center gap-2"> <button type="button" @click="reject()" class="rounded-lg border border-gray-600 bg-transparent hover:bg-gray-800 active:bg-gray-700 px-5 py-2 text-sm font-semibold text-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"> Necessary only </button> <button type="button" @click="accept()" class="rounded-lg bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2 text-sm font-semibold text-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"> Accept analytics </button> </div> </div> </div> </div> </body> </html>