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
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
}
}