Discover books, shows, and movies at your level. Track your progress by filling your Shelf with what you find, and share with other language learners. *No dusting required. shlf.space
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add draggable shelf items

This commit adds the ability for books to be dragged around the shelf.
It also adds the ability for co-located js and css files.

Signed-off-by: brookjeynes <me@brookjeynes.dev>

authored by

brookjeynes and committed by tangled.org 2631af2c 6962fa1f

+423 -104
+5 -5
docs/hacking.md
··· 15 15 16 16 You will need to fetch a series of static assets shlf depends on: 17 17 ```bash 18 - mkdir -p ./static/files 18 + mkdir -p ./static/files/js 19 19 20 20 # HTMX 21 - curl -sLo ./static/files/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js 21 + curl -sLo ./static/files/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js 22 22 # Lucide (icons) 23 - curl -sLo ./static/files/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js 23 + curl -sLo ./static/files/js/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js 24 24 # AlpineJS 25 - curl -sLo ./static/files/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js 25 + curl -sLo ./static/files/js/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js 26 26 ``` 27 27 28 28 You will need to start a redis instance - using docker can simplify this ··· 49 49 50 50 If you modified the js files, you will need to regenerate the minified versions: 51 51 ```bash 52 - minify static/*.js -o static/files/ 52 + find internal -name '*.js' | while read f; do minify "$f" -o "static/files/js/$(basename "$f")"; done 53 53 ``` 54 54
+35 -25
input.css
··· 1 1 @import "tailwindcss"; 2 + @import "./internal/views/shelf/shelf.css"; 3 + 4 + @theme { 5 + --color-ink: #000; 6 + --color-paper: #fff; 7 + --color-muted: #eee; 8 + --color-mid: #888; 9 + } 2 10 3 - @utility container { 11 + .container { 4 12 margin-inline: auto; 5 - padding: 2rem 4rem; 6 - max-width: 42rem; 13 + max-width: 80rem; 14 + padding: 2rem 1.125rem; 7 15 } 8 16 9 - @utility button { 10 - display: flex; 11 - align-items: center; 12 - gap: theme(gap.2); 13 - cursor: pointer; 14 - border-width: 1px; 15 - padding: theme(spacing.1) theme(spacing.2); 17 + @layer components { 18 + .button { 19 + display: flex; 20 + align-items: center; 21 + gap: theme(gap.2); 22 + cursor: pointer; 23 + border-width: 1px; 24 + padding: theme(spacing.1) theme(spacing.2); 16 25 17 - @variant hover { 18 - background-color: theme(colors.gray.50) 19 - } 26 + &:hover { 27 + background-color: var(--color-muted); 28 + } 20 29 21 - @variant disabled { 22 - opacity: 0.5; 23 - cursor: not-allowed; 24 - pointer-events: none; 30 + &:disabled { 31 + opacity: 0.5; 32 + cursor: not-allowed; 33 + pointer-events: none; 34 + } 25 35 } 26 - } 27 36 28 - @utility input { 29 - padding: theme(spacing.1) theme(spacing.2); 30 - border-width: 1px; 31 - font-size: theme(text.sm); 37 + .input { 38 + padding: theme(spacing.1) theme(spacing.2); 39 + border-width: 1px; 40 + font-size: theme(text.sm); 32 41 33 - @variant placeholder { 34 - opacity: 0.75; 35 - } 42 + &::placeholder { 43 + opacity: 0.75; 44 + } 45 + } 36 46 }
-8
internal/components/book/book.go
··· 1 - package book 2 - 3 - type BookParams struct { 4 - Yes24Id string 5 - Title string 6 - Author string 7 - Description string 8 - }
-34
internal/components/book/book.templ
··· 1 - package book 2 - 3 - import "fmt" 4 - 5 - templ Book(params BookParams) { 6 - <style> 7 - .hide { 8 - display: none; 9 - } 10 - .book:hover + .hide { 11 - display: block; 12 - } 13 - </style> 14 - <div class="relative inline-block"> 15 - <img 16 - alt={ fmt.Sprintf("%s's spine", params.Title) } 17 - src={ templ.SafeURL(fmt.Sprintf("https://image.yes24.com/goods/%s/SIDE/XL", params.Yes24Id)) } 18 - class="book w-6" 19 - /> 20 - <div class="hide absolute top-10 left-5 border bg-white z-10"> 21 - <div class="flex gap-2 w-96"> 22 - <div class="w-3/4"> 23 - <h2>{ params.Title }</h2> 24 - <h4>By { params.Author }</h4> 25 - <p>{ params.Description }</p> 26 - </div> 27 - <img 28 - class="w-1/4 h-fit" 29 - src={ templ.SafeURL(fmt.Sprintf("https://image.yes24.com/goods/%s/XL", params.Yes24Id)) } 30 - /> 31 - </div> 32 - </div> 33 - </div> 34 - }
+3 -3
internal/layouts/base/base.templ
··· 7 7 <meta charset="UTF-8"/> 8 8 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 9 9 <title>shlf.space - { params.Title }</title> 10 - <script src="/static/htmx.min.js" defer></script> 11 - <script src="/static/lucide.min.js"></script> 12 - <script src="/static/alpinejs.min.js" defer></script> 10 + <script src="/static/js/htmx.min.js" defer></script> 11 + <script src="/static/js/lucide.min.js"></script> 12 + <script src="/static/js/alpinejs.min.js" defer></script> 13 13 <link rel="stylesheet" href="/static/style.css" type="text/css"/> 14 14 </head> 15 15 <body class="min-h-screen">
+40
internal/server/shelf.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 + "shlf.space/internal/types" 7 8 "shlf.space/internal/views/shelf" 8 9 ) 9 10 ··· 17 18 } 18 19 handle := s.resolveDidToHandle(didOrHandle) 19 20 21 + // TODO: test data 22 + catalog := []types.Book{ 23 + { 24 + Yes24Id: "124807552", 25 + Title: "메멘과 모리", 26 + Author: "요시타케 신스케", 27 + Description: "'사람은 무엇을 위해 살아가는가?', '살아가는 의미와 목적이 필요한가?'에 대한 정답 없는 고민으로 괴로운 당신에게 요시타케 신스케가 전하는 세 가지 이야기.", 28 + }, 29 + { 30 + Yes24Id: "58552371", 31 + Title: "세계를 건너 너에게 갈게", 32 + Author: "이꽃님", 33 + Description: "'나에게. 아빠가 쓰라고 해서 쓰는 거야.' 첫 문장으로 시작한 편지가 '세계를 건너 너에게 갈게.'라는 마지막 문장에 닿기까지, 두 사람의 진심이 하나의 진실을 향해 가는 동안 쌓아올린 감동은 많은 독자들에게 울음을 울게 만들었다.", 34 + }, 35 + } 36 + 37 + // TODO: test data 38 + items := []types.ShelfItem{ 39 + { 40 + Type: types.ShelfItemBook, 41 + Book: &catalog[0], 42 + }, 43 + { 44 + Type: types.ShelfItemSpacer, 45 + }, 46 + { 47 + Type: types.ShelfItemSpacer, 48 + }, 49 + { 50 + Type: types.ShelfItemBook, 51 + Book: &catalog[1], 52 + }, 53 + { 54 + Type: types.ShelfItemSpacer, 55 + }, 56 + } 57 + 20 58 shelf.ShelfPage(shelf.ShelfPageParams{ 21 59 User: user, 22 60 ProfileHandle: handle, 61 + Catalog: catalog, 62 + Items: items, 23 63 }).Render(r.Context(), w) 24 64 }
+8
internal/types/book.go
··· 1 + package types 2 + 3 + type Book struct { 4 + Yes24Id string `json:"yes24Id"` 5 + Title string `json:"title"` 6 + Author string `json:"author"` 7 + Description string `json:"description"` 8 + }
+13
internal/types/shelf.go
··· 1 + package types 2 + 3 + type ShelfItemType string 4 + 5 + const ( 6 + ShelfItemBook ShelfItemType = "book" 7 + ShelfItemSpacer ShelfItemType = "spacer" 8 + ) 9 + 10 + type ShelfItem struct { 11 + Type ShelfItemType `json:"type"` 12 + Book *Book `json:"book,omitempty"` 13 + }
+156
internal/views/shelf/shelf.css
··· 1 + .shelf { 2 + display: grid; 3 + grid-template-columns: 5px 1fr 5px; 4 + 5 + > :first-child, 6 + > :last-child { 7 + background: var(--color-ink); 8 + } 9 + } 10 + 11 + .shelf-base { 12 + display: grid; 13 + grid-template-columns: repeat(auto-fill, minmax(2rem, 1fr)); 14 + background: var(--color-ink); 15 + border-block: 2px solid var(--color-ink); 16 + row-gap: 8px; 17 + min-height: 25rem; 18 + } 19 + 20 + .book-wrapper { 21 + position: relative; 22 + display: flex; 23 + align-items: flex-end; 24 + } 25 + 26 + @media (max-width: 768px) { 27 + .shelf-base { 28 + grid-template-columns: repeat(auto-fill, minmax(1.625rem, 1fr)); 29 + min-height: 9rem; 30 + } 31 + } 32 + 33 + @media (max-width: 640px) { 34 + .shelf-base { 35 + grid-template-columns: repeat(auto-fill, minmax(1.25rem, 1fr)); 36 + min-height: 7.5rem; 37 + } 38 + } 39 + 40 + @layer components { 41 + .item-slot { 42 + background: var(--color-paper); 43 + border: 1px dashed var(--color-muted); 44 + border-bottom: none; 45 + display: flex; 46 + align-items: flex-end; 47 + justify-content: center; 48 + position: relative; 49 + min-height: 25rem; 50 + padding-top: 1rem; 51 + 52 + &:hover { 53 + background: var(--color-muted); 54 + } 55 + 56 + &.slot-occupied { 57 + border-color: transparent; 58 + } 59 + 60 + &.slot-drag-over { 61 + background: var(--color-muted); 62 + border: 2px solid var(--color-ink); 63 + border-bottom: none; 64 + } 65 + } 66 + 67 + .book { 68 + background: var(--color-paper); 69 + width: 2rem; 70 + cursor: grab; 71 + flex-shrink: 0; 72 + overflow: hidden; 73 + 74 + &:active { 75 + cursor: grabbing; 76 + } 77 + 78 + img { 79 + width: 100%; 80 + height: auto; 81 + pointer-events: none; 82 + user-select: none; 83 + } 84 + } 85 + 86 + .book-popup { 87 + display: none; 88 + gap: 0.625rem; 89 + position: fixed; 90 + width: 17.5rem; 91 + background: var(--color-paper); 92 + border: 1px solid var(--color-ink); 93 + padding: theme(spacing.3); 94 + z-index: 100; 95 + pointer-events: none; 96 + 97 + > div { 98 + flex: 1; 99 + min-width: 0; 100 + font-weight: 300; 101 + 102 + h2 { 103 + font-size: theme(text.base); 104 + font-weight: 600; 105 + margin-bottom: 0.125rem; 106 + } 107 + 108 + h4 { 109 + font-size: theme(text.sm); 110 + margin-bottom: theme(spacing.2); 111 + } 112 + 113 + p { 114 + font-size: theme(text.xs); 115 + } 116 + } 117 + 118 + > img { 119 + width: 33.333%; 120 + } 121 + } 122 + 123 + .spacer { 124 + position: absolute; 125 + inset: 0; 126 + background: repeating-linear-gradient( 127 + 45deg, 128 + var(--color-muted), 129 + var(--color-muted) 2px, 130 + var(--color-paper) 2px, 131 + var(--color-paper) 8px 132 + ); 133 + border: 1px dashed var(--color-mid); 134 + cursor: grab; 135 + } 136 + 137 + @media (max-width: 768px) { 138 + .book { 139 + width: 1.625rem; 140 + } 141 + 142 + .item-slot { 143 + min-height: 9rem; 144 + } 145 + } 146 + 147 + @media (max-width: 640px) { 148 + .book { 149 + width: 1.25rem; 150 + } 151 + 152 + .item-slot { 153 + min-height: 7.5rem; 154 + } 155 + } 156 + }
+6 -1
internal/views/shelf/shelf.go
··· 1 1 package shelf 2 2 3 - import "shlf.space/internal/server/oauth" 3 + import ( 4 + "shlf.space/internal/server/oauth" 5 + "shlf.space/internal/types" 6 + ) 4 7 5 8 type ShelfPageParams struct { 6 9 User *oauth.AccountUser 7 10 ProfileHandle string 11 + Catalog []types.Book 12 + Items []types.ShelfItem 8 13 }
+85
internal/views/shelf/shelf.js
··· 1 + /** 2 + * @typedef {object} Book 3 + * @property {string} yes24Id 4 + * @property {string} title 5 + * @property {string} author 6 + * @property {string} description 7 + */ 8 + 9 + /** 10 + * @typedef {object} ShelfItem 11 + * @property {("book"|"spacer")} type 12 + * @property {Book | null} book 13 + */ 14 + 15 + /** Generates the data needed for shelf.templ */ 16 + function shelf(catalog, items) { 17 + return { 18 + /** @type{ShelfItem[]} */ 19 + items: items, 20 + /** @type{Book[]} */ 21 + catalog: catalog, 22 + cols: 1, 23 + dragSrcIndex: -1, 24 + popupX: 0, 25 + popupY: 0, 26 + 27 + /** Calculate the amount of shelf spots required to fill the shelf */ 28 + get slots() { 29 + const total = Math.max(this.cols, Math.ceil(this.items.length / this.cols) * this.cols); 30 + return Array.from({ length: total }, (_, i) => ({ 31 + index: i, 32 + item: i < this.items.length ? this.items[i] : null, 33 + })); 34 + }, 35 + 36 + /** Calculate the initial grid spaces for the user's viewport */ 37 + init() { 38 + new ResizeObserver(() => this.recalcCols()).observe(this.$refs.shelfBase); 39 + }, 40 + 41 + recalcCols() { 42 + const cols = getComputedStyle(this.$refs.shelfBase).gridTemplateColumns; 43 + this.cols = cols.trim().split(/\s+/).length; 44 + }, 45 + 46 + onDragStart(e, index) { 47 + this.dragSrcIndex = index; 48 + e.currentTarget.style.opacity = '0.4'; 49 + e.dataTransfer.effectAllowed = 'move'; 50 + }, 51 + 52 + onDragEnd(e) { 53 + this.dragSrcIndex = -1; 54 + e.currentTarget.style.opacity = '1'; 55 + document.querySelectorAll('.slot-drag-over').forEach(s => s.classList.remove('slot-drag-over')); 56 + }, 57 + 58 + onDrop(e, targetIndex) { 59 + e.stopPropagation(); 60 + e.currentTarget.classList.remove('slot-drag-over'); 61 + 62 + const src = this.dragSrcIndex; 63 + if (src === -1 || targetIndex === src) return; 64 + 65 + const dragged = this.items[src]; 66 + const target = this.items[targetIndex]; 67 + 68 + if (targetIndex < this.items.length) { // Dropped on existing item 69 + this.items[src] = target; 70 + this.items[targetIndex] = dragged; 71 + } else { // Dropped on empty slot 72 + const result = [...this.items]; 73 + result[src] = { type: 'spacer' }; 74 + // Fill empty slots with spacers 75 + while (result.length <= targetIndex) { 76 + result.push({ type: 'spacer' }); 77 + } 78 + result[targetIndex] = dragged; 79 + this.items = result; 80 + } 81 + 82 + this.onDragEnd(e) 83 + }, 84 + } 85 + }
+72 -28
internal/views/shelf/shelf.templ
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "shlf.space/internal/components/book" 6 5 "shlf.space/internal/components/header" 7 6 "shlf.space/internal/layouts/base" 8 7 ) 9 8 10 9 templ ShelfPage(params ShelfPageParams) { 11 - <style> 12 - .add-book { 13 - writing-mode: vertical-rl; 14 - text-orientation: upright; 15 - } 16 - </style> 10 + {{ 11 + serializedCatalog, _ := templ.JSONString(params.Catalog) 12 + serializedItems, _ := templ.JSONString(params.Items) 13 + }} 17 14 @layouts.Base(layouts.BaseParams{Title: fmt.Sprintf("%s's books", params.ProfileHandle)}) { 18 15 @header.Header(header.HeaderParams{User: params.User}) 19 - <div class="container"> 20 - <div id="book-row-1" class="flex items-end"> 21 - @book.Book(book.BookParams{ 22 - Yes24Id: "124807552", 23 - Title: "메멘과 모리", 24 - Author: "요시타케 신스케", 25 - Description: "‘사람은 무엇을 위해 살아가는가?’, ‘살아가는 의미와 목적이 필요한가?’에 대한 정답 없는 고민으로 괴로운 당신에게 요시타케 신스케가 전하는 세 가지 이야기. ", 26 - }) 27 - @book.Book(book.BookParams{ 28 - Yes24Id: "58552371", 29 - Title: "세계를 건너 너에게 갈게", 30 - Author: "이꽃님", 31 - Description: "“나에게. 아빠가 쓰라고 해서 쓰는 거야.” 첫 문장으로 시작한 편지가 “세계를 건너 너에게 갈게.”라는 마지막 문장에 닿기까지, 두 사람의 진심이 하나의 진실을 향해 가는 동안 쌓아올린 감동은 많은 독자들에게 울음을 울게 만들었다.", 32 - }) 33 - <a 34 - href="/browse" 35 - class="flex flex-col items-center justify-between gap-10 py-2 border border-dashed" 36 - > 37 - <i class="w-4 h-4" data-lucide="plus"></i> 38 - <span class="uppercase text-sm add-book">add book</span> 39 - </a> 16 + <div 17 + class="container" 18 + x-data={ fmt.Sprintf("shelf(%s, %s)", serializedCatalog, serializedItems) } 19 + > 20 + <div class="shelf"> 21 + <div id="shelf-left-side"></div> 22 + <div class="shelf-base" x-ref="shelfBase"> 23 + <template x-for="slot in slots" :key="slot.index"> 24 + <div 25 + class="item-slot" 26 + :class="{ 'slot-occupied': slot.item !== null }" 27 + @dragover.prevent="$event.dataTransfer.dropEffect = 'move'" 28 + @dragenter="slot.index !== dragSrcIndex && $event.currentTarget.classList.add('slot-drag-over')" 29 + @dragleave="$event.currentTarget.classList.remove('slot-drag-over')" 30 + @drop="onDrop($event, slot.index)" 31 + > 32 + <template x-if="slot.item?.type === 'book'"> 33 + <div 34 + class="book-wrapper" 35 + x-data="{ hovered: false }" 36 + @mouseenter="hovered = true" 37 + @mouseleave="hovered = false" 38 + @mousemove="popupX = $event.clientX; popupY = $event.clientY" 39 + > 40 + <div 41 + class="book" 42 + draggable="true" 43 + @dragstart="onDragStart($event, slot.index)" 44 + @dragend="onDragEnd($event)" 45 + @click.stop="items.splice(slot.index, 1)" 46 + > 47 + <img 48 + :src="`https://image.yes24.com/goods/${slot.item.book.yes24Id}/SIDE/XL`" 49 + :alt="slot.item.book.title" 50 + draggable="false" 51 + /> 52 + </div> 53 + <div 54 + class="book-popup" 55 + :style="`display: ${hovered && dragSrcIndex === -1 ? 'flex' : 'none'}; left: ${popupX + 12}px; bottom: ${window.innerHeight - popupY + 10}px`" 56 + > 57 + <div> 58 + <h2 x-text="slot.item.book.title"></h2> 59 + <h4 x-text="`By ${slot.item.book.author}`"></h4> 60 + <p x-text="slot.item.book.description"></p> 61 + </div> 62 + <img 63 + :src="`https://image.yes24.com/goods/${slot.item.book.yes24Id}/XL`" 64 + :alt="slot.item.book.title" 65 + draggable="false" 66 + /> 67 + </div> 68 + </div> 69 + </template> 70 + <template x-if="slot.item?.type === 'spacer'"> 71 + <div 72 + class="spacer" 73 + draggable="true" 74 + @dragstart="onDragStart($event, slot.index)" 75 + @dragend="onDragEnd($event)" 76 + @click.stop="items.splice(slot.index, 1)" 77 + ></div> 78 + </template> 79 + </div> 80 + </template> 81 + </div> 82 + <div id="shelf-right-side"></div> 40 83 </div> 41 84 </div> 85 + <script src="/static/js/shelf.js"></script> 42 86 } 43 87 }