blonk is a radar for your web, where you follow vibes for cool blips on the radar

wip

Changed files
+350 -338
lib
elixir_blonk
firehose
elixir_blonk_web
live
gif_scan_live
priv
static
assets
+5 -4
lib/elixir_blonk/firehose/consumer.ex
··· 303 303 end 304 304 end 305 305 306 - defp broadcast_to_gif_scan(full_msg, state) do 306 + defp broadcast_to_gif_scan(full_msg, _state) do 307 307 try do 308 308 # Extract post data for GIF scan 309 309 author_did = get_in(full_msg, ["did"]) ··· 313 313 external_url = extract_external_url(embed) 314 314 created_at = get_in(full_msg, ["commit", "record", "createdAt"]) 315 315 316 - # Only include tenor GIFs, sample 1 in 10 317 - if external_url && String.contains?(external_url, "media.tenor.com") && 318 - rem(state.link_count, 10) == 0 do 316 + # Include all tenor GIFs 317 + if external_url && String.contains?(external_url, "media.tenor.com") do 318 + Logger.info("Broadcasting tenor GIF: #{external_url}") 319 + 319 320 gif_data = %{ 320 321 post_uri: post_uri, 321 322 author_did: author_did,
+47 -123
lib/elixir_blonk_web/live/gif_scan_live/index.ex
··· 10 10 use ElixirBlonkWeb, :live_view 11 11 require Logger 12 12 13 - # Top 30 most common emojis for the picker 14 - @common_emojis [ 15 - "๐Ÿ˜€", "๐Ÿ˜‚", "๐Ÿคฃ", "๐Ÿ˜Š", "๐Ÿ˜", "๐Ÿฅฐ", "๐Ÿ˜˜", "๐Ÿ˜Ž", "๐Ÿค”", "๐Ÿ˜ข", 16 - "๐Ÿ˜ญ", "๐Ÿ˜ก", "๐Ÿคฌ", "๐Ÿ˜ฑ", "๐Ÿคฏ", "๐Ÿ˜ด", "๐Ÿคค", "๐Ÿ˜‹", "๐Ÿค—", "๐Ÿ™„", 17 - "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿค", "๐Ÿ’ช", "๐Ÿ”ฅ", "๐Ÿ’ฏ", "โค๏ธ", "๐Ÿ’”" 18 - ] 19 - 20 13 def mount(_params, _session, socket) do 21 14 if connected?(socket) do 22 15 # Subscribe to GIF updates ··· 25 18 26 19 socket = 27 20 socket 28 - |> assign(:gifs, []) 29 - |> assign(:search_terms, "") 30 - |> assign(:selected_emojis, []) 31 - |> assign(:common_emojis, @common_emojis) 32 - |> assign(:paused, false) 33 - |> assign(:pending_gifs, []) 34 - |> assign(:has_active_filters, false) 21 + |> assign(:grid_slots, %{}) # Map of slot_index -> gif_data 22 + |> assign(:available_slots, Enum.to_list(0..23)) # List of empty slot indices 23 + |> assign(:gif_queue, []) # Queue of GIFs waiting to be placed 24 + |> assign(:recently_updated_slots, %{}) # Map of slot_index -> timestamp for yellow highlight 35 25 36 26 {:ok, socket} 37 27 end 38 28 39 - def handle_event("search", %{"search" => %{"terms" => terms}}, socket) do 40 - trimmed_terms = String.trim(terms) 41 - has_filters = trimmed_terms != "" || socket.assigns.selected_emojis != [] 42 - 43 - socket = 44 - socket 45 - |> assign(:search_terms, trimmed_terms) 46 - |> assign(:has_active_filters, has_filters) 47 - |> maybe_clear_results_if_no_filters(has_filters) 48 - 29 + def handle_info({:new_gif_post, gif}, socket) do 30 + Logger.info("Received new GIF: #{gif.gif_url}") 31 + socket = place_gif_in_grid(socket, gif) 49 32 {:noreply, socket} 50 33 end 51 34 52 - def handle_event("toggle_emoji", %{"emoji" => emoji}, socket) do 53 - selected_emojis = socket.assigns.selected_emojis 35 + defp place_gif_in_grid(socket, gif) do 36 + available_slots = socket.assigns.available_slots 37 + grid_slots = socket.assigns.grid_slots 38 + recently_updated_slots = socket.assigns.recently_updated_slots 39 + current_time = System.system_time(:millisecond) 54 40 55 - new_selected = 56 - if emoji in selected_emojis do 57 - List.delete(selected_emojis, emoji) 58 - else 59 - [emoji | selected_emojis] 60 - end 61 - 62 - has_filters = socket.assigns.search_terms != "" || new_selected != [] 63 - 64 - socket = 65 - socket 66 - |> assign(:selected_emojis, new_selected) 67 - |> assign(:has_active_filters, has_filters) 68 - |> maybe_clear_results_if_no_filters(has_filters) 69 - 70 - {:noreply, socket} 71 - end 72 - 73 - def handle_event("toggle_pause", _params, socket) do 74 - paused = socket.assigns.paused 41 + Logger.info("Available slots: #{length(available_slots)}, Total slots: #{map_size(grid_slots)}") 75 42 76 - socket = 77 - if paused do 78 - # Resume - add pending GIFs to the feed 79 - gifs = socket.assigns.pending_gifs ++ socket.assigns.gifs 80 - socket 81 - |> assign(:paused, false) 82 - |> assign(:pending_gifs, []) 83 - |> assign(:gifs, Enum.take(gifs, 50)) # Limit to 50 GIFs 84 - else 85 - # Pause 86 - assign(socket, :paused, true) 87 - end 88 - 89 - {:noreply, socket} 90 - end 91 - 92 - def handle_event("load_new", _params, socket) do 93 - # Load pending GIFs into the main feed 94 - gifs = socket.assigns.pending_gifs ++ socket.assigns.gifs 95 - 96 - socket = 43 + if length(available_slots) > 0 do 44 + # If we have empty slots, place the GIF randomly 45 + slot_index = Enum.random(available_slots) 46 + Logger.info("Placing GIF in empty slot: #{slot_index}") 47 + 48 + # Place the GIF in that slot and mark as recently updated 49 + new_grid_slots = Map.put(grid_slots, slot_index, gif) 50 + new_available_slots = List.delete(available_slots, slot_index) 51 + new_recently_updated = Map.put(recently_updated_slots, slot_index, current_time) 52 + 97 53 socket 98 - |> assign(:pending_gifs, []) 99 - |> assign(:gifs, Enum.take(gifs, 50)) # Limit to 50 GIFs 100 - 101 - {:noreply, socket} 102 - end 103 - 104 - def handle_info({:new_gif_post, gif}, socket) do 105 - # Always process GIFs, but filter if we have active filters 106 - should_include = 107 - if socket.assigns.has_active_filters do 108 - matches_filters?(gif, socket.assigns.search_terms, socket.assigns.selected_emojis) 109 - else 110 - true # Show all GIFs when no filters are active 111 - end 112 - 113 - if should_include do 114 - socket = 115 - if socket.assigns.paused do 116 - # Add to pending GIFs when paused 117 - pending_gifs = [gif | socket.assigns.pending_gifs] 118 - assign(socket, :pending_gifs, Enum.take(pending_gifs, 25)) 119 - else 120 - # Add directly to GIFs when not paused 121 - gifs = [gif | socket.assigns.gifs] 122 - assign(socket, :gifs, Enum.take(gifs, 50)) 123 - end 124 - 125 - {:noreply, socket} 54 + |> assign(:grid_slots, new_grid_slots) 55 + |> assign(:available_slots, new_available_slots) 56 + |> assign(:recently_updated_slots, new_recently_updated) 57 + |> schedule_highlight_removal(slot_index) 126 58 else 127 - {:noreply, socket} 59 + # If all slots are filled, replace a random existing GIF 60 + slot_index = Enum.random(0..23) 61 + Logger.info("Replacing GIF in slot: #{slot_index}") 62 + 63 + # Replace the GIF in that slot and mark as recently updated 64 + new_grid_slots = Map.put(grid_slots, slot_index, gif) 65 + new_recently_updated = Map.put(recently_updated_slots, slot_index, current_time) 66 + 67 + socket 68 + |> assign(:grid_slots, new_grid_slots) 69 + |> assign(:available_slots, []) # Keep available_slots empty since all are filled 70 + |> assign(:recently_updated_slots, new_recently_updated) 71 + |> schedule_highlight_removal(slot_index) 128 72 end 129 73 end 130 74 131 - defp maybe_clear_results_if_no_filters(socket, has_filters) do 132 - # Don't clear results when filters are removed - let GIFs keep flowing 75 + defp schedule_highlight_removal(socket, slot_index) do 76 + # Schedule removal of highlight after 3 seconds 77 + Process.send_after(self(), {:remove_highlight, slot_index}, 3000) 133 78 socket 134 79 end 135 80 136 - defp matches_filters?(gif, search_terms, selected_emojis) do 137 - text = gif.text || "" 138 - 139 - # Check search terms 140 - search_match = 141 - if search_terms == "" do 142 - true 143 - else 144 - terms = String.split(search_terms, " ") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) 145 - Enum.any?(terms, fn term -> 146 - String.contains?(String.downcase(text), String.downcase(term)) 147 - end) 148 - end 149 - 150 - # Check emojis 151 - emoji_match = 152 - if selected_emojis == [] do 153 - true 154 - else 155 - Enum.any?(selected_emojis, fn emoji -> 156 - String.contains?(text, emoji) 157 - end) 158 - end 159 - 160 - search_match and emoji_match 81 + def handle_info({:remove_highlight, slot_index}, socket) do 82 + new_recently_updated = Map.delete(socket.assigns.recently_updated_slots, slot_index) 83 + socket = assign(socket, :recently_updated_slots, new_recently_updated) 84 + {:noreply, socket} 161 85 end 162 86 163 87 defp format_time(datetime) do
+74 -142
lib/elixir_blonk_web/live/gif_scan_live/index.html.heex
··· 1 - <div class="w-full min-h-screen bg-gray-50"> 2 - <!-- Controls Section - Fixed width for usability --> 3 - <div class="max-w-4xl mx-auto p-6 bg-white border-b shadow-sm"> 4 - <div class="mb-6"> 5 - <h1 class="text-3xl font-bold mb-4">GIF Scan</h1> 6 - <p class="text-gray-600 mb-6"> 7 - Search real-time GIFs from Bluesky. Enter a search term or select emojis to start scanning. 8 - </p> 9 - 10 - <!-- Search Controls --> 11 - <div class="bg-white rounded-lg shadow-md p-6 mb-6"> 12 - <div class="space-y-4"> 13 - <!-- Word Search --> 14 - <div> 15 - <label class="block text-sm font-medium text-gray-700 mb-2"> 16 - Search Words 17 - </label> 18 - <.form for={%{}} phx-submit="search"> 19 - <div class="flex gap-2"> 20 - <input 21 - type="text" 22 - name="search[terms]" 23 - value={@search_terms} 24 - placeholder="Enter words to search for in GIF posts..." 25 - class="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500" 26 - /> 27 - <button 28 - type="submit" 29 - class="px-4 py-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500" 30 - > 31 - Search 32 - </button> 33 - </div> 34 - </.form> 35 - </div> 1 + <div class="w-full h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 overflow-hidden relative"> 2 + <!-- Ambient Background Effects --> 3 + <div class="absolute inset-0 opacity-20"> 4 + <div class="absolute top-1/4 left-1/4 w-64 h-64 bg-purple-500 rounded-full blur-3xl animate-pulse"></div> 5 + <div class="absolute bottom-1/4 right-1/4 w-48 h-48 bg-blue-500 rounded-full blur-3xl animate-pulse" style="animation-delay: 2s;"></div> 6 + <div class="absolute top-3/4 left-1/2 w-32 h-32 bg-pink-500 rounded-full blur-3xl animate-pulse" style="animation-delay: 4s;"></div> 7 + </div> 36 8 37 - <!-- Emoji Picker --> 38 - <div> 39 - <label class="block text-sm font-medium text-gray-700 mb-2"> 40 - Search Emojis (click to toggle) 41 - </label> 42 - <div class="flex flex-wrap gap-2"> 43 - <%= for emoji <- @common_emojis do %> 44 - <button 45 - phx-click="toggle_emoji" 46 - phx-value-emoji={emoji} 47 - class={[ 48 - "text-2xl p-2 rounded-md border-2 transition-colors", 49 - if(emoji in @selected_emojis, 50 - do: "border-purple-500 bg-purple-50", 51 - else: "border-gray-200 hover:border-gray-300" 52 - ) 53 - ]} 54 - > 55 - <%= emoji %> 56 - </button> 57 - <% end %> 58 - </div> 59 - <%= if @selected_emojis != [] do %> 60 - <div class="mt-2 text-sm text-gray-600"> 61 - Selected: <%= Enum.join(@selected_emojis, " ") %> 62 - </div> 63 - <% end %> 9 + <!-- Top Status Bar --> 10 + <div class="absolute top-0 left-0 right-0 z-10 bg-black bg-opacity-50 backdrop-blur-sm border-b border-gray-700"> 11 + <div class="flex items-center justify-between px-6 py-3"> 12 + <div class="flex items-center gap-4"> 13 + <div class="flex items-center gap-2"> 14 + <div class="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div> 15 + <span class="text-green-400 text-sm font-mono">LIVE</span> 64 16 </div> 65 - 66 - <!-- Controls --> 67 - <%= if @has_active_filters do %> 68 - <div class="flex gap-4 items-center pt-4 border-t"> 69 - <button 70 - phx-click="toggle_pause" 71 - class={[ 72 - "px-4 py-2 rounded-md focus:outline-none focus:ring-2", 73 - if(@paused, 74 - do: "bg-green-500 text-white hover:bg-green-600 focus:ring-green-500", 75 - else: "bg-red-500 text-white hover:bg-red-600 focus:ring-red-500" 76 - ) 77 - ]} 78 - > 79 - <%= if @paused, do: "Resume Scan", else: "Pause Scan" %> 80 - </button> 81 - 82 - <%= if @paused and length(@pending_gifs) > 0 do %> 83 - <button 84 - phx-click="load_new" 85 - class="px-4 py-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500" 86 - > 87 - Load <%= length(@pending_gifs) %> New GIFs 88 - </button> 89 - <% end %> 90 - 91 - <div class="text-sm text-gray-500"> 92 - Showing <%= length(@gifs) %> GIFs 93 - <%= if @pending_gifs != [] do %> 94 - (<%= length(@pending_gifs) %> pending) 95 - <% end %> 96 - </div> 97 - </div> 98 - <% end %> 17 + <span class="text-gray-400 text-sm">GIF Wall</span> 18 + </div> 19 + <div class="text-gray-400 text-sm font-mono"> 20 + <%= map_size(@grid_slots) %>/24 ACTIVE 99 21 </div> 100 22 </div> 101 - 102 - <!-- Active Filters --> 103 - <%= if @search_terms != "" or @selected_emojis != [] do %> 104 - <div class="bg-purple-50 rounded-lg p-4 mb-6"> 105 - <h3 class="font-medium text-purple-900 mb-2">Active Filters:</h3> 106 - <div class="space-y-1 text-sm"> 107 - <%= if @search_terms != "" do %> 108 - <div class="text-purple-800">Words: <span class="font-mono"><%= @search_terms %></span></div> 109 - <% end %> 110 - <%= if @selected_emojis != [] do %> 111 - <div class="text-purple-800">Emojis: <%= Enum.join(@selected_emojis, " ") %></div> 112 - <% end %> 113 - </div> 114 - </div> 115 - <% end %> 116 - </div> 117 23 </div> 118 24 119 - <!-- GIFs Feed - Full Width Tiled Layout --> 120 - <div class="w-full"> 121 - <%= if @gifs == [] and not @has_active_filters do %> 122 - <div class="text-center py-16 text-gray-500 bg-white"> 123 - <div class="text-6xl mb-4">๐ŸŽญ</div> 124 - <h3 class="text-xl font-medium mb-2">GIF Scanner Active</h3> 125 - <p>Sampling 1 in 10 GIFs from Bluesky. Add filters above to narrow down results.</p> 126 - </div> 127 - <% else %> 128 - <%= if @gifs == [] do %> 129 - <div class="text-center py-16 text-gray-500 bg-white"> 130 - <div class="text-6xl mb-4">๐Ÿ”</div> 131 - <h3 class="text-xl font-medium mb-2">Scanning for GIFs...</h3> 132 - <p>Looking for GIFs that match your filters. Results will appear here as they're found.</p> 133 - </div> 134 - <% else %> 135 - <!-- Masonry/Tile Grid Layout --> 136 - <div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 2xl:columns-6 gap-0"> 137 - <%= for gif <- @gifs do %> 138 - <div class="break-inside-avoid relative group"> 139 - <%= if gif.gif_url do %> 25 + <!-- 6x4 Grid Layout --> 26 + <div class="grid grid-cols-6 grid-rows-4 h-full gap-2 p-6 pt-20"> 27 + <%= for slot <- 0..23 do %> 28 + <div class={[ 29 + "relative bg-gray-900 rounded-lg overflow-hidden group shadow-2xl transition-all duration-300", 30 + if(Map.has_key?(@recently_updated_slots, slot), 31 + do: "border-2 border-yellow-400 shadow-yellow-400/50 shadow-2xl", 32 + else: "border border-gray-800 hover:border-gray-600" 33 + ) 34 + ]}> 35 + <%= if Map.has_key?(@grid_slots, slot) do %> 36 + <%= case Map.get(@grid_slots, slot) do %> 37 + <% %{gif_url: gif_url, text: text} when gif_url != nil -> %> 38 + <!-- Active GIF Slot --> 39 + <div class="relative w-full h-full"> 140 40 <img 141 - src={gif.gif_url} 41 + src={gif_url} 142 42 alt="GIF" 143 - class="w-full h-auto block" 43 + class="w-full h-full object-cover transition-all duration-700 group-hover:scale-105" 144 44 loading="lazy" 145 45 /> 146 - <%= if gif.text && String.trim(gif.text) != "" do %> 147 - <div class="absolute inset-x-0 bottom-0 bg-black bg-opacity-75 text-white p-2 text-sm opacity-0 group-hover:opacity-100 transition-opacity"> 148 - <%= gif.text %> 46 + <!-- TV Scanlines Effect --> 47 + <div class="absolute inset-0 bg-gradient-to-b from-transparent via-black to-transparent opacity-10 pointer-events-none" 48 + style="background-image: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.1) 2px, rgba(0,0,0,0.1) 4px);"></div> 49 + 50 + <!-- Corner Indicators --> 51 + <div class="absolute top-2 right-2 w-2 h-2 bg-green-400 rounded-full opacity-75"></div> 52 + 53 + <!-- Text Overlay --> 54 + <%= if text && String.trim(text) != "" do %> 55 + <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black via-black to-transparent text-white p-3 text-xs opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0"> 56 + <%= text %> 149 57 </div> 150 58 <% end %> 151 - <% end %> 152 - </div> 59 + </div> 60 + <% _ -> %> 61 + <!-- Empty/Loading Slot --> 62 + <div class="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center"> 63 + <div class="relative"> 64 + <div class="w-4 h-4 bg-gray-600 rounded-full animate-ping"></div> 65 + <div class="absolute inset-0 w-4 h-4 bg-gray-500 rounded-full animate-pulse"></div> 66 + </div> 67 + </div> 153 68 <% end %> 154 - </div> 155 - <% end %> 69 + <% else %> 70 + <!-- Empty/Loading Slot --> 71 + <div class="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center"> 72 + <div class="relative"> 73 + <div class="w-4 h-4 bg-gray-600 rounded-full animate-ping"></div> 74 + <div class="absolute inset-0 w-4 h-4 bg-gray-500 rounded-full animate-pulse"></div> 75 + </div> 76 + </div> 77 + <% end %> 78 + </div> 156 79 <% end %> 80 + </div> 81 + 82 + <!-- Bottom Info Bar --> 83 + <div class="absolute bottom-0 left-0 right-0 z-10 bg-black bg-opacity-50 backdrop-blur-sm border-t border-gray-700"> 84 + <div class="flex items-center justify-center px-6 py-2"> 85 + <span class="text-gray-400 text-xs font-mono"> 86 + BLUESKY FIREHOSE โ€ข REAL-TIME GIF STREAM โ€ข MEDIA.TENOR.COM 87 + </span> 88 + </div> 157 89 </div> 158 90 </div>
+224 -69
priv/static/assets/app.css
··· 857 857 border-width: 0; 858 858 } 859 859 860 + .pointer-events-none { 861 + pointer-events: none; 862 + } 863 + 860 864 .static { 861 865 position: static; 862 866 } ··· 904 908 bottom: 0px; 905 909 } 906 910 911 + .bottom-1\/4 { 912 + bottom: 25%; 913 + } 914 + 907 915 .left-0 { 908 916 left: 0px; 917 + } 918 + 919 + .left-1\/2 { 920 + left: 50%; 921 + } 922 + 923 + .left-1\/4 { 924 + left: 25%; 909 925 } 910 926 911 927 .left-\[40rem\] { ··· 920 936 right: 0.25rem; 921 937 } 922 938 939 + .right-1\/4 { 940 + right: 25%; 941 + } 942 + 923 943 .right-2 { 924 944 right: 0.5rem; 925 945 } 926 946 927 947 .right-5 { 928 948 right: 1.25rem; 949 + } 950 + 951 + .top-0 { 952 + top: 0px; 929 953 } 930 954 931 955 .top-1 { 932 956 top: 0.25rem; 933 957 } 934 958 959 + .top-1\/4 { 960 + top: 25%; 961 + } 962 + 935 963 .top-2 { 936 964 top: 0.5rem; 937 965 } 938 966 939 967 .top-20 { 940 968 top: 5rem; 969 + } 970 + 971 + .top-3\/4 { 972 + top: 75%; 941 973 } 942 974 943 975 .top-6 { ··· 1117 1149 height: 0.75rem; 1118 1150 } 1119 1151 1152 + .h-32 { 1153 + height: 8rem; 1154 + } 1155 + 1120 1156 .h-4 { 1121 1157 height: 1rem; 1122 1158 } 1123 1159 1160 + .h-48 { 1161 + height: 12rem; 1162 + } 1163 + 1124 1164 .h-5 { 1125 1165 height: 1.25rem; 1126 1166 } ··· 1129 1169 height: 1.5rem; 1130 1170 } 1131 1171 1132 - .h-auto { 1133 - height: auto; 1172 + .h-64 { 1173 + height: 16rem; 1134 1174 } 1135 1175 1136 1176 .h-full { 1137 1177 height: 100%; 1138 1178 } 1139 1179 1180 + .h-screen { 1181 + height: 100vh; 1182 + } 1183 + 1140 1184 .min-h-\[200px\] { 1141 1185 min-height: 200px; 1142 1186 } ··· 1147 1191 1148 1192 .min-h-full { 1149 1193 min-height: 100%; 1150 - } 1151 - 1152 - .min-h-screen { 1153 - min-height: 100vh; 1154 1194 } 1155 1195 1156 1196 .w-1\/4 { ··· 1167 1207 1168 1208 .w-14 { 1169 1209 width: 3.5rem; 1210 + } 1211 + 1212 + .w-2 { 1213 + width: 0.5rem; 1170 1214 } 1171 1215 1172 1216 .w-3 { ··· 1181 1225 width: 1rem; 1182 1226 } 1183 1227 1228 + .w-48 { 1229 + width: 12rem; 1230 + } 1231 + 1184 1232 .w-5 { 1185 1233 width: 1.25rem; 1186 1234 } 1187 1235 1188 1236 .w-6 { 1189 1237 width: 1.5rem; 1238 + } 1239 + 1240 + .w-64 { 1241 + width: 16rem; 1190 1242 } 1191 1243 1192 1244 .w-8 { ··· 1262 1314 transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1263 1315 } 1264 1316 1317 + .translate-y-2 { 1318 + --tw-translate-y: 0.5rem; 1319 + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1320 + } 1321 + 1265 1322 .translate-y-4 { 1266 1323 --tw-translate-y: 1rem; 1267 1324 transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); ··· 1271 1328 transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1272 1329 } 1273 1330 1331 + @keyframes ping { 1332 + 75%, 100% { 1333 + transform: scale(2); 1334 + opacity: 0; 1335 + } 1336 + } 1337 + 1338 + .animate-ping { 1339 + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; 1340 + } 1341 + 1342 + @keyframes pulse { 1343 + 50% { 1344 + opacity: .5; 1345 + } 1346 + } 1347 + 1348 + .animate-pulse { 1349 + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 1350 + } 1351 + 1274 1352 @keyframes spin { 1275 1353 to { 1276 1354 transform: rotate(360deg); ··· 1289 1367 resize: none; 1290 1368 } 1291 1369 1292 - .columns-2 { 1293 - -moz-columns: 2; 1294 - columns: 2; 1370 + .grid-cols-1 { 1371 + grid-template-columns: repeat(1, minmax(0, 1fr)); 1295 1372 } 1296 1373 1297 - .break-inside-avoid { 1298 - -moz-column-break-inside: avoid; 1299 - break-inside: avoid; 1374 + .grid-cols-2 { 1375 + grid-template-columns: repeat(2, minmax(0, 1fr)); 1300 1376 } 1301 1377 1302 - .grid-cols-1 { 1303 - grid-template-columns: repeat(1, minmax(0, 1fr)); 1378 + .grid-cols-6 { 1379 + grid-template-columns: repeat(6, minmax(0, 1fr)); 1304 1380 } 1305 1381 1306 - .grid-cols-2 { 1307 - grid-template-columns: repeat(2, minmax(0, 1fr)); 1382 + .grid-rows-4 { 1383 + grid-template-rows: repeat(4, minmax(0, 1fr)); 1308 1384 } 1309 1385 1310 1386 .flex-col { ··· 1335 1411 justify-content: space-between; 1336 1412 } 1337 1413 1338 - .gap-0 { 1339 - gap: 0px; 1340 - } 1341 - 1342 1414 .gap-1 { 1343 1415 gap: 0.25rem; 1344 1416 } ··· 1530 1602 border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1531 1603 } 1532 1604 1605 + .border-gray-700 { 1606 + --tw-border-opacity: 1; 1607 + border-color: rgb(55 65 81 / var(--tw-border-opacity)); 1608 + } 1609 + 1610 + .border-gray-800 { 1611 + --tw-border-opacity: 1; 1612 + border-color: rgb(31 41 55 / var(--tw-border-opacity)); 1613 + } 1614 + 1533 1615 .border-green-400 { 1534 1616 --tw-border-opacity: 1; 1535 1617 border-color: rgb(74 222 128 / var(--tw-border-opacity)); ··· 1543 1625 .border-pink-400 { 1544 1626 --tw-border-opacity: 1; 1545 1627 border-color: rgb(244 114 182 / var(--tw-border-opacity)); 1546 - } 1547 - 1548 - .border-purple-500 { 1549 - --tw-border-opacity: 1; 1550 - border-color: rgb(168 85 247 / var(--tw-border-opacity)); 1551 1628 } 1552 1629 1553 1630 .border-red-200 { ··· 1629 1706 background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 1630 1707 } 1631 1708 1709 + .bg-gray-500 { 1710 + --tw-bg-opacity: 1; 1711 + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); 1712 + } 1713 + 1632 1714 .bg-gray-600 { 1633 1715 --tw-bg-opacity: 1; 1634 1716 background-color: rgb(75 85 99 / var(--tw-bg-opacity)); 1635 1717 } 1636 1718 1719 + .bg-gray-900 { 1720 + --tw-bg-opacity: 1; 1721 + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 1722 + } 1723 + 1724 + .bg-green-400 { 1725 + --tw-bg-opacity: 1; 1726 + background-color: rgb(74 222 128 / var(--tw-bg-opacity)); 1727 + } 1728 + 1637 1729 .bg-green-500 { 1638 1730 --tw-bg-opacity: 1; 1639 1731 background-color: rgb(34 197 94 / var(--tw-bg-opacity)); 1640 1732 } 1641 1733 1642 - .bg-purple-50 { 1734 + .bg-pink-500 { 1643 1735 --tw-bg-opacity: 1; 1644 - background-color: rgb(250 245 255 / var(--tw-bg-opacity)); 1736 + background-color: rgb(236 72 153 / var(--tw-bg-opacity)); 1645 1737 } 1646 1738 1647 1739 .bg-purple-500 { ··· 1687 1779 --tw-bg-opacity: 0.5; 1688 1780 } 1689 1781 1690 - .bg-opacity-75 { 1691 - --tw-bg-opacity: 0.75; 1782 + .bg-gradient-to-b { 1783 + background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); 1784 + } 1785 + 1786 + .bg-gradient-to-br { 1787 + background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); 1788 + } 1789 + 1790 + .bg-gradient-to-t { 1791 + background-image: linear-gradient(to top, var(--tw-gradient-stops)); 1792 + } 1793 + 1794 + .from-black { 1795 + --tw-gradient-from: #000 var(--tw-gradient-from-position); 1796 + --tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position); 1797 + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); 1798 + } 1799 + 1800 + .from-gray-800 { 1801 + --tw-gradient-from: #1f2937 var(--tw-gradient-from-position); 1802 + --tw-gradient-to: rgb(31 41 55 / 0) var(--tw-gradient-to-position); 1803 + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); 1804 + } 1805 + 1806 + .from-gray-900 { 1807 + --tw-gradient-from: #111827 var(--tw-gradient-from-position); 1808 + --tw-gradient-to: rgb(17 24 39 / 0) var(--tw-gradient-to-position); 1809 + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); 1810 + } 1811 + 1812 + .from-transparent { 1813 + --tw-gradient-from: transparent var(--tw-gradient-from-position); 1814 + --tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position); 1815 + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); 1816 + } 1817 + 1818 + .via-black { 1819 + --tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position); 1820 + --tw-gradient-stops: var(--tw-gradient-from), #000 var(--tw-gradient-via-position), var(--tw-gradient-to); 1821 + } 1822 + 1823 + .to-gray-900 { 1824 + --tw-gradient-to: #111827 var(--tw-gradient-to-position); 1825 + } 1826 + 1827 + .to-transparent { 1828 + --tw-gradient-to: transparent var(--tw-gradient-to-position); 1692 1829 } 1693 1830 1694 1831 .fill-cyan-900 { ··· 1701 1838 1702 1839 .fill-zinc-400 { 1703 1840 fill: #a1a1aa; 1841 + } 1842 + 1843 + .object-cover { 1844 + -o-object-fit: cover; 1845 + object-fit: cover; 1704 1846 } 1705 1847 1706 1848 .p-0 { ··· 1823 1965 padding-right: 1.5rem; 1824 1966 } 1825 1967 1968 + .pt-20 { 1969 + padding-top: 5rem; 1970 + } 1971 + 1826 1972 .pt-4 { 1827 1973 padding-top: 1rem; 1828 1974 } ··· 2005 2151 color: rgb(17 24 39 / var(--tw-text-opacity)); 2006 2152 } 2007 2153 2008 - .text-purple-800 { 2154 + .text-green-400 { 2009 2155 --tw-text-opacity: 1; 2010 - color: rgb(107 33 168 / var(--tw-text-opacity)); 2011 - } 2012 - 2013 - .text-purple-900 { 2014 - --tw-text-opacity: 1; 2015 - color: rgb(88 28 135 / var(--tw-text-opacity)); 2156 + color: rgb(74 222 128 / var(--tw-text-opacity)); 2016 2157 } 2017 2158 2018 2159 .text-red-700 { ··· 2064 2205 opacity: 0; 2065 2206 } 2066 2207 2208 + .opacity-10 { 2209 + opacity: 0.1; 2210 + } 2211 + 2067 2212 .opacity-100 { 2068 2213 opacity: 1; 2069 2214 } ··· 2076 2221 opacity: 0.4; 2077 2222 } 2078 2223 2224 + .opacity-75 { 2225 + opacity: 0.75; 2226 + } 2227 + 2228 + .shadow-2xl { 2229 + --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 2230 + --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 2231 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 2232 + } 2233 + 2079 2234 .shadow-lg { 2080 2235 --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 2081 2236 --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); ··· 2099 2254 --tw-shadow: var(--tw-shadow-colored); 2100 2255 } 2101 2256 2257 + .shadow-yellow-400\/50 { 2258 + --tw-shadow-color: rgb(250 204 21 / 0.5); 2259 + --tw-shadow: var(--tw-shadow-colored); 2260 + } 2261 + 2102 2262 .outline { 2103 2263 outline-style: solid; 2104 2264 } ··· 2123 2283 --tw-ring-color: rgb(63 63 70 / 0.1); 2124 2284 } 2125 2285 2126 - .filter { 2286 + .blur-3xl { 2287 + --tw-blur: blur(64px); 2127 2288 filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 2128 2289 } 2129 2290 2291 + .backdrop-blur-sm { 2292 + --tw-backdrop-blur: blur(4px); 2293 + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 2294 + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 2295 + } 2296 + 2130 2297 .transition { 2131 2298 transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 2132 2299 transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; ··· 2161 2328 transition-duration: 300ms; 2162 2329 } 2163 2330 2331 + .duration-700 { 2332 + transition-duration: 700ms; 2333 + } 2334 + 2164 2335 .ease-in { 2165 2336 transition-timing-function: cubic-bezier(0.4, 0, 1, 1); 2166 2337 } ··· 2193 2364 border-color: rgb(209 213 219 / var(--tw-border-opacity)); 2194 2365 } 2195 2366 2367 + .hover\:border-gray-600:hover { 2368 + --tw-border-opacity: 1; 2369 + border-color: rgb(75 85 99 / var(--tw-border-opacity)); 2370 + } 2371 + 2196 2372 .hover\:bg-blue-200:hover { 2197 2373 --tw-bg-opacity: 1; 2198 2374 background-color: rgb(191 219 254 / var(--tw-bg-opacity)); ··· 2231 2407 .hover\:bg-green-600:hover { 2232 2408 --tw-bg-opacity: 1; 2233 2409 background-color: rgb(22 163 74 / var(--tw-bg-opacity)); 2234 - } 2235 - 2236 - .hover\:bg-purple-600:hover { 2237 - --tw-bg-opacity: 1; 2238 - background-color: rgb(147 51 234 / var(--tw-bg-opacity)); 2239 2410 } 2240 2411 2241 2412 .hover\:bg-red-600:hover { ··· 2338 2509 --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); 2339 2510 } 2340 2511 2341 - .focus\:ring-purple-500:focus { 2342 - --tw-ring-opacity: 1; 2343 - --tw-ring-color: rgb(168 85 247 / var(--tw-ring-opacity)); 2344 - } 2345 - 2346 2512 .focus\:ring-red-500:focus { 2347 2513 --tw-ring-opacity: 1; 2348 2514 --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity)); ··· 2350 2516 2351 2517 .active\:text-white\/80:active { 2352 2518 color: rgb(255 255 255 / 0.8); 2519 + } 2520 + 2521 + .group:hover .group-hover\:translate-y-0 { 2522 + --tw-translate-y: 0px; 2523 + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 2524 + } 2525 + 2526 + .group:hover .group-hover\:scale-105 { 2527 + --tw-scale-x: 1.05; 2528 + --tw-scale-y: 1.05; 2529 + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 2353 2530 } 2354 2531 2355 2532 .group:hover .group-hover\:bg-zinc-100 { ··· 2487 2664 width: 75%; 2488 2665 } 2489 2666 2490 - .md\:columns-3 { 2491 - -moz-columns: 3; 2492 - columns: 3; 2493 - } 2494 - 2495 2667 .md\:grid-cols-2 { 2496 2668 grid-template-columns: repeat(2, minmax(0, 1fr)); 2497 2669 } ··· 2515 2687 width: 50%; 2516 2688 } 2517 2689 2518 - .lg\:columns-4 { 2519 - -moz-columns: 4; 2520 - columns: 4; 2521 - } 2522 - 2523 2690 .lg\:grid-cols-3 { 2524 2691 grid-template-columns: repeat(3, minmax(0, 1fr)); 2525 2692 } ··· 2540 2707 left: 50rem; 2541 2708 } 2542 2709 2543 - .xl\:columns-5 { 2544 - -moz-columns: 5; 2545 - columns: 5; 2546 - } 2547 - 2548 2710 .xl\:grid-cols-4 { 2549 2711 grid-template-columns: repeat(4, minmax(0, 1fr)); 2550 2712 } ··· 2559 2721 padding-bottom: 8rem; 2560 2722 } 2561 2723 } 2562 - 2563 - @media (min-width: 1536px) { 2564 - .\32xl\:columns-6 { 2565 - -moz-columns: 6; 2566 - columns: 6; 2567 - } 2568 - }