diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 8735fe66..40fc5ccf 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -49,6 +49,15 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(87 100% 37%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.97 0 0); + --code-foreground: oklch(0.145 0 0); + --code-number: oklch(0.556 0 0); + + /* spacing and text */ + --spacing: 0.25rem; + --text-sm: 0.875rem; } .dark { @@ -90,6 +99,11 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(84 81% 44%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.205 0 0); + --code-foreground: oklch(0.985 0 0); + --code-number: oklch(0.708 0 0); } @theme inline { @@ -135,6 +149,9 @@ --color-warning-foreground: var(--warning-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-code: var(--code); + --color-code-foreground: var(--code-foreground); + --color-code-number: var(--code-number); } /* Container settings */ @@ -152,3 +169,28 @@ @apply bg-background text-foreground; } } + +/* Shiki line numbers */ +code[data-line-numbers] { + counter-reset: line; + font-size: var(--text-sm); +} + +[data-line-numbers] [data-line]::before { + font-size: var(--text-sm); + counter-increment: line; + content: counter(line); + width: calc(var(--spacing) * 16); + padding-right: calc(var(--spacing) * 6); + text-align: right; + color: var(--color-code-number); + background-color: var(--color-code); + display: inline-block; + position: sticky; + left: 0; +} + +[data-line-numbers] [data-line] { + display: inline-block; + width: 100%; +} diff --git a/app/blocks/sidebar_01.rb b/app/blocks/sidebar_01.rb new file mode 100644 index 00000000..906686ad --- /dev/null +++ b/app/blocks/sidebar_01.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar01 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon() + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon() + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon() + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: { strategy: "fixed", placement: "right-start" }) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02.rb b/app/blocks/sidebar_02.rb new file mode 100644 index 00000000..24907358 --- /dev/null +++ b/app/blocks/sidebar_02.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar02 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def view_template + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02/app_sidebar.rb b/app/blocks/sidebar_02/app_sidebar.rb new file mode 100644 index 00000000..6b5e339f --- /dev/null +++ b/app/blocks/sidebar_02/app_sidebar.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +class Blocks::Sidebar02::AppSidebar < Views::Base + def view_template + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + Blocks::Sidebar02::Index::FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + Blocks::Sidebar02::Index::WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def message_circle_question + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_line_join: "round", + class: "lucide lucide-message-circle-question-icon lucide-message-circle-question" + ) do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end +end diff --git a/app/blocks/sidebar_02/index.rb b/app/blocks/sidebar_02/index.rb new file mode 100644 index 00000000..b6efbe53 --- /dev/null +++ b/app/blocks/sidebar_02/index.rb @@ -0,0 +1,203 @@ +class Blocks::Sidebar02::Index < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + render Blocks::Sidebar02::AppSidebar.new + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + Separator(orientation: :vertical, class: "mr-2 h-4") + Breadcrumb do + BreadcrumbList do + BreadcrumbItem(class: "hidden md:block") do + BreadcrumbLink(href: "#") { "Building Your Application" } + end + BreadcrumbSeparator(class: "hidden md:block") + BreadcrumbItem do + BreadcrumbPage { "Data Fetching" } + end + end + end + end + div(class: "flex flex-1 flex-col gap-4 p-4") do + div(class: "grid auto-rows-min gap-4 md:grid-cols-3") do + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + end + div(class: "min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/components/block_display.rb b/app/components/block_display.rb new file mode 100644 index 00000000..b521ec1c --- /dev/null +++ b/app/components/block_display.rb @@ -0,0 +1,443 @@ +class Components::BlockDisplay < Components::Base + def initialize(content:, description: nil) + @description = description + @content = content + @files = extract_files_from_block + end + + def view_template + div( + class: "group/block-view-wrapper", + data: { + controller: "custom-tabs block-code-viewer", + tab: "preview", + action: "tab-change->custom-tabs#setTab" + } + ) do + tool_bar + block_viewer_view + block_viewer_code + end + end + + def tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + Separator(orientation: :vertical) + + render_header + end + end + end + end + + def render_tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + end + end + end + + def block_viewer_view + div(class: "hidden group-data-[tab=code]/block-view-wrapper:hidden md:h-(--height) lg:flex") do + div(class: "relative grid w-full gap-4") do + div(class: "absolute inset-0 right-4 [background-image:radial-gradient(#d4d4d4_1px,transparent_1px)] [background-size:20px_20px] dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]") + render_preview_tab + end + end + end + + def block_viewer_code + div(class: "bg-code text-code-foreground mr-[14px] flex overflow-hidden rounded-xl border group-data-[tab=preview]/block-view-wrapper:hidden h-[600px]") do + render_file_tree + render_code_content + end + end + + def render_file_tree + div(class: "w-72") do + div( + data_slot: "sidebar-wrapper", + style: "--sidebar-width:16rem;--sidebar-width-icon:3rem", + class: "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar min-h-svh w-full flex !min-h-full flex-col border-r" + ) do + div( + data_slot: "sidebar", + class: "bg-sidebar text-sidebar-foreground flex h-full flex-col w-full flex-1" + ) do + SidebarGroupLabel(data_slot: "sidebar-group-label", class: "h-12 rounded-none border-b px-4 text-sm") { "Files" } + SidebarGroup(data_slot: "sidebar-group", class: "p-0") do + SidebarGroupContent(data_slot: "sidebar-group-content") do + SidebarMenu(data_slot: "sidebar-menu", class: "translate-x-0 gap-1.5") do + render_file_tree_items + end + end + end + end + end + end + end + + def render_file_tree_items + grouped_files = group_files_by_directory + grouped_files.each do |dir, files| + render_directory_item(dir, files) + end + end + + def render_directory_item(dir, files) + depth = dir.split("/").size - 1 + padding_left = "#{1 + (depth * 1.4)}rem" + + SidebarMenuItem(data_slot: "sidebar-menu-item") do + Collapsible( + open: true, + data_slot: "collapsible", + class: "group/collapsible" + ) do + CollapsibleTrigger(data_slot: "collapsible-trigger") do + SidebarMenuButton( + as: :button, + type: "button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}" + ) do + chevron_icon(collapsible: true) + folder_icon + span { dir.split("/").last } + end + end + CollapsibleContent(data_slot: "collapsible-content") do + SidebarMenuSub(data_slot: "sidebar-menu-sub", class: "m-0 w-full translate-x-0 border-none p-0") do + files.each_with_index do |file, index| + render_file_item(file, index, depth + 1) + end + end + end + end + end + end + + def render_file_item(file, index, depth) + padding_left = "#{1 + (depth * 1.4) + 0.5}rem" + + SidebarMenuSubItem(data_slot: "sidebar-menu-item") do + SidebarMenuButton( + active: index == 0, + as: :button, + data_slot: "sidebar-menu-button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}", + data: { + action: "click->block-code-viewer#selectFile", + file_path: file[:path], + block_code_viewer_target: "fileButton", + index: depth + } + ) do + chevron_icon(invisible: true) + file_icon + span { file[:name] } + end + end + end + + def group_files_by_directory + grouped = {} + @files.each do |file| + dir = File.dirname(file[:path]) + grouped[dir] ||= [] + grouped[dir] << file + end + grouped + end + + def render_code_content + figure(class: "!mx-0 mt-0 flex min-w-0 flex-1 flex-col rounded-xl border-none overflow-hidden") do + render_file_header + render_code_body + end + end + + def render_file_header + @files.each_with_index do |file, index| + figcaption( + class: "text-code-foreground [&_svg]:text-code-foreground relative flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 [&_svg]:size-4 [&_svg]:opacity-70 #{(index == 0) ? "" : "hidden"}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileHeader" + } + ) do + file_icon + span(class: "text-sm font-light") { file[:path] } + + # Clipboard button + RubyUI.Clipboard(success: "Copied!", error: "Copy failed!", class: "absolute right-2") do + RubyUI.ClipboardSource do + pre(class: "hidden") { plain file[:code] } + end + RubyUI.ClipboardTrigger do + RubyUI.Button( + variant: :ghost, + size: :icon, + class: "size-7" + ) { clipboard_icon } + end + end + end + end + end + + def render_code_body + @files.each_with_index do |file, index| + div( + class: "overflow-y-auto flex-1 min-h-0 #{(index == 0) ? "" : "hidden"}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileContent" + } + ) do + render CodeblockWithLineNumbers.new(code: file[:code], syntax: file[:syntax]) + end + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents + TabsContent(value: "preview") { render_preview_tab } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock("@display_code", syntax: :ruby, class: "-m-px") + end + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + + def chevron_icon(invisible: false, collapsible: false) + classes = ["lucide", "lucide-chevron-right", "transition-transform", "duration-200"] + classes << "invisible" if invisible + classes << "group-data-[state=open]/collapsible:rotate-90" if collapsible + + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: classes.join(" ") + ) do |s| + s.path(d: "m9 18 6-6-6-6") + end + end + + def folder_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-folder" + ) do |s| + s.path(d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z") + end + end + + def file_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-file h-4 w-4" + ) do |s| + s.path(d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z") + s.path(d: "M14 2v4a2 2 0 0 0 2 2h4") + end + end + + def clipboard_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-clipboard" + ) do |s| + s.rect(width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1") + s.path(d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2") + end + end + + private + + def extract_files_from_block + files = [] + + main_file_path = get_file_path_for_class(@content) + if File.exist?(main_file_path) + content = File.read(main_file_path) + files << { + name: File.basename(main_file_path), + path: relative_path_from_app(main_file_path), + code: content, + syntax: :ruby + } + end + + if @content.to_s.include?("::") + dir_path = File.dirname(main_file_path) + if File.directory?(dir_path) && dir_path != File.dirname(dir_path) + Dir.glob("#{dir_path}/*.rb").sort.each do |file_path| + next if file_path == main_file_path + content = File.read(file_path) + files << { + name: File.basename(file_path), + path: relative_path_from_app(file_path), + code: content, + syntax: :ruby + } + end + end + end + + files + end + + def get_file_path_for_class(klass) + parts = klass.to_s.split("::") + filename = parts.pop.underscore + path_parts = parts.map(&:underscore) + + Rails.root.join("app", *path_parts, "#{filename}.rb").to_s + end + + def relative_path_from_app(absolute_path) + absolute_path.sub(Rails.root.join("app").to_s + "/", "") + end + + # Inner component for rendering code with Shiki highlighting + class CodeblockWithLineNumbers < Components::Base + def initialize(code:, syntax:) + @code = code + @syntax = syntax + end + + def view_template + div( + class: "relative", + data: { + controller: "shiki-highlighter", + shiki_highlighter_language_value: @syntax.to_s + } + ) do + # Hidden code content for Shiki to process + pre( + class: "hidden", + data: {shiki_highlighter_target: "code"} + ) do + plain @code + end + + # Output container for Shiki-generated HTML + div( + class: "bg-code text-code-foreground overflow-y-auto", + data: {shiki_highlighter_target: "output"} + ) + end + end + end +end diff --git a/app/components/block_viewer.rb b/app/components/block_viewer.rb new file mode 100644 index 00000000..d3879ccc --- /dev/null +++ b/app/components/block_viewer.rb @@ -0,0 +1,23 @@ +class Components::BlockViewer < Components::Base + def view_template + render ComponentPreview.new(title: "Example", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + # render ComponentPreview.new( + # title: "Example", + # context: self, + # type: :block, + # content: Blocks::Sidebar02::Index, + # content_attributes: {sidebar_state: "open"} + # ) + end +end diff --git a/app/components/component_preview.rb b/app/components/component_preview.rb new file mode 100644 index 00000000..c2da35fb --- /dev/null +++ b/app/components/component_preview.rb @@ -0,0 +1,24 @@ +module Components + class ComponentPreview < Components::Base + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code + @title = title + @description = description + @src = src + @context = context + @type = type + @content = content + @content_attributes = content_attributes + end + + def view_template(&) + if @type == :block && @content + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + else + render ComponentPreviewTabs.new(title: @title, description: @description, context: @context, ruby_code: @ruby_code) { capture(&) } + end + end + end +end diff --git a/app/components/component_preview_tabs.rb b/app/components/component_preview_tabs.rb new file mode 100644 index 00000000..230a6877 --- /dev/null +++ b/app/components/component_preview_tabs.rb @@ -0,0 +1,107 @@ +module Components + class ComponentPreviewTabs < Components::Base + def initialize(context:, ruby_code: nil, title: nil, description: nil) + @title = title + @description = description + @context = context + @ruby_code = ruby_code + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + div(id: @title) do + div(class: "relative") do + Tabs(default_value: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + render_header + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + render_tab_contents(&) + end + end + end + # render ComponentRender.new(context: @context, ruby_code: @ruby_code) { capture(&) } + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents(&) + TabsContent(value: "preview") { render_preview_tab(&) } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab(&) + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock(@display_code, syntax: :ruby, class: "-m-px") + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + end +end diff --git a/app/components/component_render.rb b/app/components/component_render.rb new file mode 100644 index 00000000..a2f77b28 --- /dev/null +++ b/app/components/component_render.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Components + class ComponentRender < Components::Base + def initialize(context:, ruby_code: nil) + @ruby_code = ruby_code + @context = context + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + # standard:enable Style/ArgumentsForwarding + end +end diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..12aabd6b 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -13,15 +13,19 @@ def self.reset_collected_code @@collected_code = [] end - def initialize(title: nil, description: nil, src: nil, context: nil) + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code @title = title @description = description @src = src @context = context + @type = type + @content = content + @content_attributes = content_attributes end def view_template(&) - @display_code = CGI.unescapeHTML(capture(&)) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) @@collected_code << @display_code div(id: @title) do @@ -55,7 +59,7 @@ def render_header def render_tab_triggers TabsList do render_tab_trigger("preview", "Preview", method(:eye_icon)) - render_tab_trigger("code", "Code", method(:code_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) if @type == :component end end @@ -72,15 +76,25 @@ def render_tab_contents(&) end def render_preview_tab(&block) - return iframe_preview if @src + block_class_name = @content.to_s + + return iframe_preview(block_class_name) if @type == :block raw_preview end - def iframe_preview + def iframe_preview(block_name) div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do - iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) + if @content + iframe(src: render_block_path(id: block_name, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + else + iframe(srcdoc: safe("