diff --git a/docs/hacking.md b/docs/hacking.md index 9769a71..01603f2 100644 --- a/docs/hacking.md +++ b/docs/hacking.md @@ -15,14 +15,14 @@ export SHLF_OAUTH_CLIENT_SECRET="$(goat key generate -t P-256 | grep -A1 "Secret You will need to fetch a series of static assets shlf depends on: ```bash -mkdir -p ./static/files +mkdir -p ./static/files/js # HTMX -curl -sLo ./static/files/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js +curl -sLo ./static/files/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js # Lucide (icons) -curl -sLo ./static/files/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js +curl -sLo ./static/files/js/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js # AlpineJS -curl -sLo ./static/files/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js +curl -sLo ./static/files/js/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js ``` You will need to start a redis instance - using docker can simplify this @@ -49,6 +49,6 @@ tailwindcss -i ./input.css -o ./static/files/style.css If you modified the js files, you will need to regenerate the minified versions: ```bash -minify static/*.js -o static/files/ +find internal -name '*.js' | while read f; do minify "$f" -o "static/files/js/$(basename "$f")"; done ``` diff --git a/input.css b/input.css index 74df78e..55d8ea8 100644 --- a/input.css +++ b/input.css @@ -1,36 +1,46 @@ @import "tailwindcss"; +@import "./internal/views/shelf/shelf.css"; -@utility container { +@theme { + --color-ink: #000; + --color-paper: #fff; + --color-muted: #eee; + --color-mid: #888; +} + +.container { margin-inline: auto; - padding: 2rem 4rem; - max-width: 42rem; + max-width: 80rem; + padding: 2rem 1.125rem; } -@utility button { - display: flex; - align-items: center; - gap: theme(gap.2); - cursor: pointer; - border-width: 1px; - padding: theme(spacing.1) theme(spacing.2); +@layer components { + .button { + display: flex; + align-items: center; + gap: theme(gap.2); + cursor: pointer; + border-width: 1px; + padding: theme(spacing.1) theme(spacing.2); - @variant hover { - background-color: theme(colors.gray.50) - } + &:hover { + background-color: var(--color-muted); + } - @variant disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } } -} -@utility input { - padding: theme(spacing.1) theme(spacing.2); - border-width: 1px; - font-size: theme(text.sm); + .input { + padding: theme(spacing.1) theme(spacing.2); + border-width: 1px; + font-size: theme(text.sm); - @variant placeholder { - opacity: 0.75; - } + &::placeholder { + opacity: 0.75; + } + } } diff --git a/internal/components/book/book.go b/internal/components/book/book.go deleted file mode 100644 index 4941510..0000000 --- a/internal/components/book/book.go +++ /dev/null @@ -1,8 +0,0 @@ -package book - -type BookParams struct { - Yes24Id string - Title string - Author string - Description string -} diff --git a/internal/components/book/book.templ b/internal/components/book/book.templ deleted file mode 100644 index 90353f3..0000000 --- a/internal/components/book/book.templ +++ /dev/null @@ -1,34 +0,0 @@ -package book - -import "fmt" - -templ Book(params BookParams) { - -
- { -
-
-
-

{ params.Title }

-

By { params.Author }

-

{ params.Description }

-
- -
-
-
-} diff --git a/internal/layouts/base/base.templ b/internal/layouts/base/base.templ index b93697e..4eee3e4 100644 --- a/internal/layouts/base/base.templ +++ b/internal/layouts/base/base.templ @@ -7,9 +7,9 @@ templ Base(params BaseParams) { shlf.space - { params.Title } - - - + + + diff --git a/internal/server/shelf.go b/internal/server/shelf.go index 940ff0f..51baa38 100644 --- a/internal/server/shelf.go +++ b/internal/server/shelf.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "shlf.space/internal/types" "shlf.space/internal/views/shelf" ) @@ -17,8 +18,47 @@ func (s *Server) Shelf(w http.ResponseWriter, r *http.Request) { } handle := s.resolveDidToHandle(didOrHandle) + // TODO: test data + catalog := []types.Book{ + { + Yes24Id: "124807552", + Title: "메멘과 모리", + Author: "요시타케 신스케", + Description: "'사람은 무엇을 위해 살아가는가?', '살아가는 의미와 목적이 필요한가?'에 대한 정답 없는 고민으로 괴로운 당신에게 요시타케 신스케가 전하는 세 가지 이야기.", + }, + { + Yes24Id: "58552371", + Title: "세계를 건너 너에게 갈게", + Author: "이꽃님", + Description: "'나에게. 아빠가 쓰라고 해서 쓰는 거야.' 첫 문장으로 시작한 편지가 '세계를 건너 너에게 갈게.'라는 마지막 문장에 닿기까지, 두 사람의 진심이 하나의 진실을 향해 가는 동안 쌓아올린 감동은 많은 독자들에게 울음을 울게 만들었다.", + }, + } + + // TODO: test data + items := []types.ShelfItem{ + { + Type: types.ShelfItemBook, + Book: &catalog[0], + }, + { + Type: types.ShelfItemSpacer, + }, + { + Type: types.ShelfItemSpacer, + }, + { + Type: types.ShelfItemBook, + Book: &catalog[1], + }, + { + Type: types.ShelfItemSpacer, + }, + } + shelf.ShelfPage(shelf.ShelfPageParams{ User: user, ProfileHandle: handle, + Catalog: catalog, + Items: items, }).Render(r.Context(), w) } diff --git a/internal/types/book.go b/internal/types/book.go new file mode 100644 index 0000000..d4e4295 --- /dev/null +++ b/internal/types/book.go @@ -0,0 +1,8 @@ +package types + +type Book struct { + Yes24Id string `json:"yes24Id"` + Title string `json:"title"` + Author string `json:"author"` + Description string `json:"description"` +} diff --git a/internal/types/shelf.go b/internal/types/shelf.go new file mode 100644 index 0000000..2807e3c --- /dev/null +++ b/internal/types/shelf.go @@ -0,0 +1,13 @@ +package types + +type ShelfItemType string + +const ( + ShelfItemBook ShelfItemType = "book" + ShelfItemSpacer ShelfItemType = "spacer" +) + +type ShelfItem struct { + Type ShelfItemType `json:"type"` + Book *Book `json:"book,omitempty"` +} diff --git a/internal/views/shelf/shelf.css b/internal/views/shelf/shelf.css new file mode 100644 index 0000000..15b6de9 --- /dev/null +++ b/internal/views/shelf/shelf.css @@ -0,0 +1,156 @@ +.shelf { + display: grid; + grid-template-columns: 5px 1fr 5px; + + > :first-child, + > :last-child { + background: var(--color-ink); + } +} + +.shelf-base { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(2rem, 1fr)); + background: var(--color-ink); + border-block: 2px solid var(--color-ink); + row-gap: 8px; + min-height: 25rem; +} + +.book-wrapper { + position: relative; + display: flex; + align-items: flex-end; +} + +@media (max-width: 768px) { + .shelf-base { + grid-template-columns: repeat(auto-fill, minmax(1.625rem, 1fr)); + min-height: 9rem; + } +} + +@media (max-width: 640px) { + .shelf-base { + grid-template-columns: repeat(auto-fill, minmax(1.25rem, 1fr)); + min-height: 7.5rem; + } +} + +@layer components { + .item-slot { + background: var(--color-paper); + border: 1px dashed var(--color-muted); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: center; + position: relative; + min-height: 25rem; + padding-top: 1rem; + + &:hover { + background: var(--color-muted); + } + + &.slot-occupied { + border-color: transparent; + } + + &.slot-drag-over { + background: var(--color-muted); + border: 2px solid var(--color-ink); + border-bottom: none; + } + } + + .book { + background: var(--color-paper); + width: 2rem; + cursor: grab; + flex-shrink: 0; + overflow: hidden; + + &:active { + cursor: grabbing; + } + + img { + width: 100%; + height: auto; + pointer-events: none; + user-select: none; + } + } + + .book-popup { + display: none; + gap: 0.625rem; + position: fixed; + width: 17.5rem; + background: var(--color-paper); + border: 1px solid var(--color-ink); + padding: theme(spacing.3); + z-index: 100; + pointer-events: none; + + > div { + flex: 1; + min-width: 0; + font-weight: 300; + + h2 { + font-size: theme(text.base); + font-weight: 600; + margin-bottom: 0.125rem; + } + + h4 { + font-size: theme(text.sm); + margin-bottom: theme(spacing.2); + } + + p { + font-size: theme(text.xs); + } + } + + > img { + width: 33.333%; + } + } + + .spacer { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 45deg, + var(--color-muted), + var(--color-muted) 2px, + var(--color-paper) 2px, + var(--color-paper) 8px + ); + border: 1px dashed var(--color-mid); + cursor: grab; + } + + @media (max-width: 768px) { + .book { + width: 1.625rem; + } + + .item-slot { + min-height: 9rem; + } + } + + @media (max-width: 640px) { + .book { + width: 1.25rem; + } + + .item-slot { + min-height: 7.5rem; + } + } +} diff --git a/internal/views/shelf/shelf.go b/internal/views/shelf/shelf.go index d5ba8fc..7248617 100644 --- a/internal/views/shelf/shelf.go +++ b/internal/views/shelf/shelf.go @@ -1,8 +1,13 @@ package shelf -import "shlf.space/internal/server/oauth" +import ( + "shlf.space/internal/server/oauth" + "shlf.space/internal/types" +) type ShelfPageParams struct { User *oauth.AccountUser ProfileHandle string + Catalog []types.Book + Items []types.ShelfItem } diff --git a/internal/views/shelf/shelf.js b/internal/views/shelf/shelf.js new file mode 100644 index 0000000..3bfa097 --- /dev/null +++ b/internal/views/shelf/shelf.js @@ -0,0 +1,85 @@ +/** + * @typedef {object} Book + * @property {string} yes24Id + * @property {string} title + * @property {string} author + * @property {string} description + */ + +/** + * @typedef {object} ShelfItem + * @property {("book"|"spacer")} type + * @property {Book | null} book + */ + +/** Generates the data needed for shelf.templ */ +function shelf(catalog, items) { + return { + /** @type{ShelfItem[]} */ + items: items, + /** @type{Book[]} */ + catalog: catalog, + cols: 1, + dragSrcIndex: -1, + popupX: 0, + popupY: 0, + + /** Calculate the amount of shelf spots required to fill the shelf */ + get slots() { + const total = Math.max(this.cols, Math.ceil(this.items.length / this.cols) * this.cols); + return Array.from({ length: total }, (_, i) => ({ + index: i, + item: i < this.items.length ? this.items[i] : null, + })); + }, + + /** Calculate the initial grid spaces for the user's viewport */ + init() { + new ResizeObserver(() => this.recalcCols()).observe(this.$refs.shelfBase); + }, + + recalcCols() { + const cols = getComputedStyle(this.$refs.shelfBase).gridTemplateColumns; + this.cols = cols.trim().split(/\s+/).length; + }, + + onDragStart(e, index) { + this.dragSrcIndex = index; + e.currentTarget.style.opacity = '0.4'; + e.dataTransfer.effectAllowed = 'move'; + }, + + onDragEnd(e) { + this.dragSrcIndex = -1; + e.currentTarget.style.opacity = '1'; + document.querySelectorAll('.slot-drag-over').forEach(s => s.classList.remove('slot-drag-over')); + }, + + onDrop(e, targetIndex) { + e.stopPropagation(); + e.currentTarget.classList.remove('slot-drag-over'); + + const src = this.dragSrcIndex; + if (src === -1 || targetIndex === src) return; + + const dragged = this.items[src]; + const target = this.items[targetIndex]; + + if (targetIndex < this.items.length) { // Dropped on existing item + this.items[src] = target; + this.items[targetIndex] = dragged; + } else { // Dropped on empty slot + const result = [...this.items]; + result[src] = { type: 'spacer' }; + // Fill empty slots with spacers + while (result.length <= targetIndex) { + result.push({ type: 'spacer' }); + } + result[targetIndex] = dragged; + this.items = result; + } + + this.onDragEnd(e) + }, + } +} diff --git a/internal/views/shelf/shelf.templ b/internal/views/shelf/shelf.templ index bf7564d..fba401b 100644 --- a/internal/views/shelf/shelf.templ +++ b/internal/views/shelf/shelf.templ @@ -2,42 +2,86 @@ package shelf import ( "fmt" - "shlf.space/internal/components/book" "shlf.space/internal/components/header" "shlf.space/internal/layouts/base" ) templ ShelfPage(params ShelfPageParams) { - + {{ + serializedCatalog, _ := templ.JSONString(params.Catalog) + serializedItems, _ := templ.JSONString(params.Items) + }} @layouts.Base(layouts.BaseParams{Title: fmt.Sprintf("%s's books", params.ProfileHandle)}) { @header.Header(header.HeaderParams{User: params.User}) -
-
- @book.Book(book.BookParams{ - Yes24Id: "124807552", - Title: "메멘과 모리", - Author: "요시타케 신스케", - Description: "‘사람은 무엇을 위해 살아가는가?’, ‘살아가는 의미와 목적이 필요한가?’에 대한 정답 없는 고민으로 괴로운 당신에게 요시타케 신스케가 전하는 세 가지 이야기. ", - }) - @book.Book(book.BookParams{ - Yes24Id: "58552371", - Title: "세계를 건너 너에게 갈게", - Author: "이꽃님", - Description: "“나에게. 아빠가 쓰라고 해서 쓰는 거야.” 첫 문장으로 시작한 편지가 “세계를 건너 너에게 갈게.”라는 마지막 문장에 닿기까지, 두 사람의 진심이 하나의 진실을 향해 가는 동안 쌓아올린 감동은 많은 독자들에게 울음을 울게 만들었다.", - }) - - - add book - +
+
+
+
+ +
+
+ } }