;; Search and actor card components ;; Aliases — delegate to shared ~shared:misc/avatar (defcomp ~search/actor-avatar-img (&key (src :as string) (cls :as string)) (~shared:misc/avatar :src src :cls cls)) (defcomp ~search/actor-avatar-placeholder (&key (cls :as string) (initial :as string)) (~shared:misc/avatar :cls cls :initial initial)) (defcomp ~search/actor-name-link (&key (href :as string) (name :as string)) (a :href href :class "font-semibold text-stone-900 hover:underline" name)) (defcomp ~search/actor-name-link-external (&key (href :as string) (name :as string)) (a :href href :target "_blank" :rel "noopener" :class "font-semibold text-stone-900 hover:underline" name)) (defcomp ~search/actor-summary (&key (summary :as string)) (div :class "text-sm text-stone-600 mt-1 truncate" (~rich-text :html summary))) (defcomp ~search/unfollow-button (&key (action :as string) (csrf :as string) (actor-url :as string)) (div :class "flex-shrink-0" (form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML" (input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "actor_url" :value actor-url) (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))) (defcomp ~search/follow-button (&key (action :as string) (csrf :as string) (actor-url :as string) (label :as string)) (div :class "flex-shrink-0" (form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML" (input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "actor_url" :value actor-url) (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label)))) (defcomp ~search/actor-card (&key (cls :as string) (id :as string) avatar name (username :as string) (domain :as string) summary button) (article :class cls :id id avatar (div :class "flex-1 min-w-0" name (div :class "text-sm text-stone-500" "@" username "@" domain) summary) button)) ;; Data-driven actor card (replaces Python _actor_card_sx loop) (defcomp ~search/actor-card-from-data (&key (d :as dict) (has-actor :as boolean) (csrf :as string) (follow-url :as string) (unfollow-url :as string) (list-type :as string)) (let* ((icon-url (get d "icon_url")) (display-name (get d "display_name")) (username (get d "username")) (domain (get d "domain")) (actor-url (get d "actor_url")) (safe-id (get d "safe_id")) (initial (or (get d "initial") "?")) (avatar (~shared:misc/avatar :src icon-url :cls (if icon-url "w-12 h-12 rounded-full" "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") :initial (when (not icon-url) initial))) (name-sx (if (get d "external_link") (~search/actor-name-link-external :href (get d "name_href") :name display-name) (~search/actor-name-link :href (get d "name_href") :name display-name))) (summary-sx (when (get d "summary") (~search/actor-summary :summary (get d "summary")))) (is-followed (get d "is_followed")) (button (when has-actor (if (or (= list-type "following") is-followed) (~search/unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url) (~search/follow-button :action follow-url :csrf csrf :actor-url actor-url :label (if (= list-type "followers") "Follow Back" "Follow")))))) (~search/actor-card :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" :id (str "actor-" safe-id) :avatar avatar :name name-sx :username username :domain domain :summary summary-sx :button button))) ;; Data-driven actor list (replaces Python _search_results_sx / _actor_list_items_sx loops) (defcomp ~search/actor-list-from-data (&key (actors :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string) (follow-url :as string) (unfollow-url :as string) (list-type :as string)) (<> (map (lambda (d) (~search/actor-card-from-data :d d :has-actor has-actor :csrf csrf :follow-url follow-url :unfollow-url unfollow-url :list-type list-type)) (or actors (list))) (when next-url (~social/scroll-sentinel :url next-url)))) (defcomp ~search/info (&key (cls :as string) (text :as string)) (p :class cls text)) (defcomp ~search/page (&key (search-url :as string) (search-page-url :as string) (query :as string) info results) (h1 :class "text-2xl font-bold mb-6" "Search") (form :method "get" :action search-url :class "mb-6" :sx-get search-page-url :sx-target "#search-results" :sx-push-url search-url (div :class "flex gap-2" (input :type "text" :name "q" :value query :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500" :placeholder "Search users or @user@instance.tld") (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search"))) info (div :id "search-results" results)) ;; Following / Followers list page (defcomp ~search/actor-list-page (&key (title :as string) (count-str :as string) items) (h1 :class "text-2xl font-bold mb-6" title " " (span :class "text-stone-400 font-normal" count-str)) (div :id "actor-list" items)) ;; --------------------------------------------------------------------------- ;; Assembled actor card — replaces Python _actor_card_sx ;; --------------------------------------------------------------------------- (defcomp ~search/actor-card-from-data (&key (a :as dict) actor (followed-urls :as list) (list-type :as string)) (let* ((display-name (or (get a "display_name") (get a "preferred_username") "")) (username (or (get a "preferred_username") "")) (domain (or (get a "domain") "")) (icon-url (get a "icon_url")) (actor-url (or (get a "actor_url") "")) (summary (get a "summary")) (aid (get a "id")) (safe-id (replace (replace actor-url "/" "_") ":" "_")) (initial (if (and (not icon-url) (or display-name username)) (upper (slice (or display-name username) 0 1)) "?")) (csrf (csrf-token)) (is-followed (contains? (or followed-urls (list)) actor-url))) (~search/actor-card :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" :id (str "actor-" safe-id) :avatar (~shared:misc/avatar :src icon-url :cls (if icon-url "w-12 h-12 rounded-full" "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") :initial (when (not icon-url) initial)) :name (if (and (or (= list-type "following") (= list-type "search")) aid) (~search/actor-name-link :href (url-for "social.defpage_actor_timeline" :id aid) :name (escape display-name)) (~search/actor-name-link-external :href (str "https://" domain "/@" username) :name (escape display-name))) :username (escape username) :domain (escape domain) :summary (when summary (~search/actor-summary :summary summary)) :button (when actor (if (or (= list-type "following") is-followed) (~search/unfollow-button :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url) (~search/follow-button :action (url-for "social.follow") :csrf csrf :actor-url actor-url :label (if (= list-type "followers") "Follow Back" "Follow"))))))) ;; Assembled search content — replaces Python _search_content_sx (defcomp ~search/content (&key (query :as string?) (actors :as list) (total :as number) (followed-urls :as list) actor) (~search/page :search-url (url-for "social.defpage_search") :search-page-url (url-for "social.search_page") :query (escape (or query "")) :info (cond ((and query (> total 0)) (~search/info :cls "text-sm text-stone-500 mb-4" :text (str total " result" (pluralize total) " for " (escape query)))) (query (~search/info :cls "text-stone-500 mb-4" :text (str "No results found for " (escape query)))) (true nil)) :results (when (not (empty? actors)) (<> (map (lambda (a) (~search/actor-card-from-data :a a :actor actor :followed-urls followed-urls :list-type "search")) actors) (when (>= (len actors) 20) (~social/scroll-sentinel :url (url-for "social.search_page" :q query :page 2))))))) ;; Assembled following/followers content — replaces Python _following_content_sx etc. (defcomp ~search/following-content (&key (actors :as list) (total :as number) actor) (~search/actor-list-page :title "Following" :count-str (str "(" total ")") :items (when (not (empty? actors)) (<> (map (lambda (a) (~search/actor-card-from-data :a a :actor actor :followed-urls (list) :list-type "following")) actors) (when (>= (len actors) 20) (~social/scroll-sentinel :url (url-for "social.following_list_page" :page 2))))))) (defcomp ~search/followers-content (&key (actors :as list) (total :as number) (followed-urls :as list) actor) (~search/actor-list-page :title "Followers" :count-str (str "(" total ")") :items (when (not (empty? actors)) (<> (map (lambda (a) (~search/actor-card-from-data :a a :actor actor :followed-urls followed-urls :list-type "followers")) actors) (when (>= (len actors) 20) (~social/scroll-sentinel :url (url-for "social.followers_list_page" :page 2)))))))