わいえむねっと

Contents
Categories
Calendar
2026/08
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Monthly Archives
~2000/01
Recent Entries
RSS1.0
Templates
Information
Processed: 0.163 sec
Chashed: -
2026/08/23 Sun

TweetVault④

前回

TweetVault①
TweetVault②
TweetVault③

絞り込み UI の改善

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

  • CTRL を押しながら選択した場合は複数選択にする。選択済みだった場合は解除。
  • 後述の除外選択中に CTRL で選択された場合は状態を上書き。
  • ALT を押しながら選択した場合は除外するタグの選択にする。こちらも複数選択可。選択済みだった場合は解除。通常選択されていた場合は状態を上書き。
  • 通常選択のハイライトが青なのに対して、除外選択は赤とする。
  • 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)
     }
検索の実体。
テキストボックスの入力からタグ検索する処理は除去。複数キーワード対応は残す。
元の処理に合わせるためにいったんアンマーシャルしているのが微妙。