Return to Default
2026/08/23

TweetVault④

前回

TweetVault①
TweetVault②
TweetVault③

絞り込み UI の改善

テキストボックスにタグを入力する形で複数条件の絞り込みに対応したけど、タグ一覧から選択するようにしたい。

  • 選択済みの場合は解除。
  • 後述の除外選択中のタグを Ctrl で選択した場合は状態を上書き。
  • こちらも複数選択可。
  • 選択済みの場合は解除。
  • 通常選択中のタグを Alt で選択した場合は状態を上書き。
  • 通常選択のハイライトが青なのに対して、こちらは赤にする。
  • 複数選択や除外選択されているタグがある場合は解除する。

frontend/src/app/page.tsx
@@ -17,3 +17,3 @@
   const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
-  const [selectedTag, setSelectedTag] = useState<string>();
+  const [selectedTag, setSelectedTag] = useState({inc:[], exc:[]});
   const [searchQuery, setSearchQuery] = useState('');
@@ -54,3 +54,3 @@
       const data = await api.getBookmarks({
-        tag: selectedTag,
+        tag: JSON.stringify(selectedTag),
         search: searchQuery,
@@ -117,4 +117,26 @@
 
-  function handleTagSelect(tag: string | undefined) {
-    setSelectedTag(tag);
+  function handleTagSelect(tag: string | undefined, e?: any) {
+    if (!tag) {
+      setSelectedTag({inc:[], exc:[]});
+      return;
+    }
+    setSelectedTag(prev => {
+      if (e && e.ctrlKey) {
+        return {
+          inc: prev.inc.includes(tag)
+             ? prev.inc.filter(item => item !== tag)
+             : [...prev.inc, tag],
+          exc: prev.exc.filter(item => item !== tag)
+        };
+      } else if (e && e.altKey) {
+        return {
+          exc: prev.exc.includes(tag)
+             ? prev.exc.filter(item => item !== tag)
+             : [...prev.exc, tag],
+          inc: prev.inc.filter(item => item !== tag)
+        };
+      } else {
+        return {inc:[tag], exc:[]};
+      }
+    });
     setCurrentPage(1);
選択中のタグを保持する selectedTag を、文字列から対象/対象外リストを保持するオブジェクトに変更。
Ctrl/Alt 押下時の処理を追加。

frontend/src/components/bookmarks/SearchAndFilter.tsx
@@ -8,3 +8,3 @@
   onTagSelect: (tag: string | undefined) => void;
-  selectedTag?: string;
+  selectedTag?: {};
   onDeleteTag?: (tagName: string) => void;
@@ -88,3 +88,3 @@
       // If the deleted tag was selected, reset to show all bookmarks
-      if (selectedTag === tagName) {
+      if (selectedTag.inc.includes(tagName) || selectedTag.exc.includes(tagName)) {
         onTagSelect(undefined);
@@ -122,3 +122,3 @@
             className={`rounded-full px-3 py-1 text-sm whitespace-nowrap ${
-              !selectedTag
+              selectedTag.inc.length == 0 && selectedTag.exc.length == 0
                 ? 'bg-blue-600 text-white'
@@ -132,6 +132,6 @@
               <div
-                onClick={() => onTagSelect(tag.name)}
+                onClick={(e) => onTagSelect(tag.name, e)}
                 className={`px-3 py-1 text-sm whitespace-nowrap flex items-center cursor-pointer ${
-                  selectedTag === tag.name
-                    ? 'bg-blue-600 text-white'
+                  selectedTag.inc.includes(tag.name) ? 'bg-blue-600 text-white' :
+                  selectedTag.exc.includes(tag.name) ? 'bg-red-600 text-white'
                     : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600'
選択中タグに関する判定を修正。
キーボードイベントの引き渡しを追加。

frontend/src/components/bookmarks/TagMenu.tsx
@@ -8,3 +8,3 @@
   onSuccess: () => void;
-  selectedTag?: string;
+  selectedTag?: {};
   onDeleteTag?: (tagName: string) => void;
@@ -134,3 +134,4 @@
           className={`p-1 rounded-full cursor-pointer ${
-            selectedTag === tag.name ? 'hover:bg-blue-700' : 'text-gray-500 hover:bg-gray-200'
+            selectedTag.inc.includes(tag.name) ? 'hover:bg-blue-700' :
+            selectedTag.exc.includes(tag.name) ? 'hover:bg-red-700'  :  'text-gray-500 hover:bg-gray-200'
           }`}
選択中タグに関する判定を修正。

backend/internal/services/bookmark_service.go(前回からの差分)
@@ -4,3 +4,2 @@
     "encoding/json"
-    "fmt"
     "log"
@@ -8,2 +7,3 @@
     "strings"
+    "slices"
 
@@ -197,6 +197,9 @@
 
-    type Tag struct {
+    type RequestTag struct {
+        Include []string `json:"inc"`
+        Exclude []string `json:"exc"`
+    }
+    type QueryTag struct {
         Name string `json:"name"`
     }
-    var tags []Tag
 
@@ -208,6 +211,24 @@
     // Apply tag filter if provided
-    if tag == "untagged" {
-        query = query.Where("tags_json::jsonb = ?", "[]")
-    } else if tag != "" {
-        tags = append(tags, Tag{Name: tag})
+    if tag != "" {
+        var req RequestTag
+        if err := json.Unmarshal([]byte(tag), &req); err != nil {
+            return nil, 0, err
+        }
+        if slices.Contains(req.Include, "untagged") {
+            query = query.Where("tags_json::jsonb = ?", "[]")
+        } else {
+            if len(req.Include) != 0 {
+                var tags []QueryTag
+                for _, v := range req.Include {
+                    tags = append(tags, QueryTag{Name: v})
+                }
+                tagsJson, _ := json.Marshal(tags)
+                query = query.Where("tags_json::jsonb @> ?", tagsJson)
+            }
+            for _, v := range req.Exclude {
+                tags := []QueryTag{{Name: v}}
+                tagsJson, _ := json.Marshal(tags)
+                query = query.Not("tags_json::jsonb @> ?", tagsJson)
+            }
+        }
     }
@@ -218,18 +239,5 @@
         for _, word := range words {
-            if strings.HasPrefix(word, "#") {
-                tags = append(tags, Tag{Name: strings.TrimPrefix(word, "#")})
-            } else if strings.HasPrefix(word, "-#") {
-                query = query.Not("tags_json::jsonb @> ?", fmt.Sprintf(`[{"name":"%s"}]`, strings.TrimPrefix(word, "-#")))
-            } else {
-                query = query.Where("full_text ILIKE ? OR name ILIKE ? OR screen_name ILIKE ?",
-                "%"+word+"%", "%"+word+"%", "%"+word+"%")
-            }
-        }
-    }
-    if len(tags) != 0 {
-        jsonData, err := json.Marshal(tags)
-        if err != nil {
-            return nil, 0, err
+            query = query.Where("full_text ILIKE ? OR name ILIKE ? OR screen_name ILIKE ?",
+            "%"+word+"%", "%"+word+"%", "%"+word+"%")
         }
-        query = query.Where("tags_json::jsonb @> ?", jsonData)
     }
検索の実体。
テキストボックスの入力からタグ検索する処理は除去。複数キーワード対応は残す。
元の処理に合わせるためにいったんアンマーシャルしているのが微妙。

2026/08/22
胆のう摘出の手術をしてから二か月が経過。
術後の状態確認で MRI を撮ってくる。
結果は特に問題なし。

あと、今回の MRI でも膵臓には何も写りませんでした。
過去に診断された IPMN はなかったことになった。

先生「他に何か気になることはありますか」
なや「腎臓で石が見つかっていたのが気になってるくらいですかね」
先生「(写真をスクロール)あーこれですね。これが下に落ちると…」
なや「めちゃくちゃ痛いやつですね」
先生「そう、めちゃくちゃ痛いやつww」

不意に「痛いやつ」って差し込まれてツボに入ったのか、先生爆笑してた。
スーパーの弁当コーナーでなんにしよと眺めてたら、店員さんに案内されてきたお婆さんが鮭ハラス飯を買っていき、うまそうだなと思ったので自分も購入。

帰ってから気付いたけど、これ二人前だな?
今日はかよちゃん。

土砂降りだったけど、雨雲レーダーを見た感じ2時間もすれば通り過ぎそう。
飲んでいるうちに上がるだろ。
足元びしょ濡れになるだろうから、靴下は脱いでサンダルで出掛ける。


かよちゃん - 下総中山/居酒屋 | 食べログ
https://tabelog.co​m/chiba/A1202/A120202/12020652/





いつものように栄川を二合。
惣菜の盛り合わせ。




お次は木戸泉を二合。
今日は いかの丸干しがあったぞ。



女将さんが以前勤めていたスーパーの店長さんが今も昼営業に来てくれているって話は前に聞いていたけど、いつも定食と惣菜盛り合わせを食べながらビールに日本酒三合を飲んでいると聞いてびびる。
その量飲んで仕事戻ってるの。
雨はすっかり上がった。予定通り。
今日は雨上がりのにおいがしないなと思いつつ、江戸川沿いを歩く。



またサンダルで10km超歩いてる。


今日も元気。
2026/08/21
冷凍庫のロックアイスがでかいかたまりになってた。
一回溶けたなこれ。
開けっ放しになってたか?

前に買い替えたのが2009年なので、そろそろ故障してもおかしくはないんだよな。
歯医者20回目。
仮歯無限ループ。