+5
-4
lib/elixir_blonk/firehose/consumer.ex
+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
+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
+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
+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
-
}