mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 19:45:07 +00:00
wowa
This commit is contained in:
parent
5846407c14
commit
2100894475
9 changed files with 257 additions and 276 deletions
|
|
@ -12,7 +12,7 @@ class Components::Admin::Search::Index < Components::Base
|
|||
end
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
div(class: "container-lg p-4") do
|
||||
header_section
|
||||
tabs_section
|
||||
search_form
|
||||
|
|
@ -23,47 +23,37 @@ class Components::Admin::Search::Index < Components::Base
|
|||
private
|
||||
|
||||
def header_section
|
||||
header(style: "margin-bottom: 24px;") do
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Admin Search" }
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
|
||||
div(class: "mb-4") do
|
||||
h1(class: "h2 mb-1") { "Admin Search" }
|
||||
p(class: "color-fg-muted f5") do
|
||||
"Search users and uploads by ID, email, filename, URL, etc."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def tabs_section
|
||||
div(style: "display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do
|
||||
tab_link("All", "all")
|
||||
tab_link("Users", "users")
|
||||
tab_link("Uploads", "uploads")
|
||||
end
|
||||
end
|
||||
|
||||
def tab_link(label, type)
|
||||
active = @type == type
|
||||
base_style = "padding: 8px 16px; text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px;"
|
||||
active_style = active ? "border-color: #0969da; color: #0969da; font-weight: 500;" : "color: var(--fgColor-muted);"
|
||||
|
||||
link_to admin_search_path(type: type, q: @query), style: "#{base_style} #{active_style}" do
|
||||
plain label
|
||||
render Primer::Alpha::UnderlineNav.new(label: "Search type") do |nav|
|
||||
nav.with_tab(selected: @type == "all", href: admin_search_path(type: "all", q: @query)) { "All" }
|
||||
nav.with_tab(selected: @type == "users", href: admin_search_path(type: "users", q: @query)) { "Users" }
|
||||
nav.with_tab(selected: @type == "uploads", href: admin_search_path(type: "uploads", q: @query)) { "Uploads" }
|
||||
end
|
||||
end
|
||||
|
||||
def search_form
|
||||
div(style: "margin-bottom: 24px;") do
|
||||
form_with url: admin_search_path, method: :get, style: "display: flex; gap: 8px;" do
|
||||
div(class: "mb-4 mt-3") do
|
||||
form_with url: admin_search_path, method: :get, class: "d-flex gap-2" do
|
||||
input(type: "hidden", name: "type", value: @type)
|
||||
input(
|
||||
type: "search",
|
||||
name: "q",
|
||||
placeholder: search_placeholder,
|
||||
value: @query,
|
||||
class: "form-control",
|
||||
style: "flex: 1; max-width: 600px;",
|
||||
class: "form-control flex-1",
|
||||
style: "max-width: 600px;",
|
||||
autofocus: true
|
||||
)
|
||||
button(type: "submit", class: "btn btn-primary") do
|
||||
render Primer::Beta::Octicon.new(icon: :search, mr: 1)
|
||||
render Primer::Beta::Button.new(type: :submit, scheme: :primary) do |button|
|
||||
button.with_leading_visual_icon(icon: :search)
|
||||
plain "Search"
|
||||
end
|
||||
end
|
||||
|
|
@ -88,43 +78,55 @@ class Components::Admin::Search::Index < Components::Base
|
|||
end
|
||||
|
||||
def users_section
|
||||
div(style: "margin-bottom: 32px;") do
|
||||
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do
|
||||
div(class: "mb-5") do
|
||||
h2(class: "h4 mb-3") do
|
||||
plain "Users "
|
||||
span(style: "color: var(--fgColor-muted); font-weight: normal;") { "(#{@users.size})" }
|
||||
render Primer::Beta::Label.new(scheme: :secondary) { @users.size.to_s }
|
||||
end
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
@users.each do |user|
|
||||
user_row(user)
|
||||
box.with_row do
|
||||
user_row(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def user_row(user)
|
||||
div(style: "padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de); display: flex; justify-content: space-between; align-items: center;") do
|
||||
div(class: "d-flex flex-justify-between flex-items-center") do
|
||||
div do
|
||||
div(style: "font-weight: 500;") { user.name || "Unnamed" }
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted);") do
|
||||
div(class: "text-bold") { user.name || "Unnamed" }
|
||||
div(class: "f6 color-fg-muted") do
|
||||
plain user.email
|
||||
plain " · "
|
||||
code(style: "font-size: 11px;") { user.public_id }
|
||||
code(class: "f6") { user.public_id }
|
||||
end
|
||||
end
|
||||
div(style: "display: flex; align-items: center; gap: 16px;") do
|
||||
div(style: "text-align: right; font-size: 12px; color: var(--fgColor-muted);") do
|
||||
div(class: "d-flex flex-items-center gap-3") do
|
||||
div(class: "text-right f6 color-fg-muted") do
|
||||
div { "#{user.total_files} files" }
|
||||
div { user.total_storage_formatted }
|
||||
if user.is_admin?
|
||||
span(style: "background: #8250df; color: white; padding: 2px 6px; border-radius: 4px; font-size: 10px; margin-left: 8px;") { "ADMIN" }
|
||||
end
|
||||
end
|
||||
div(style: "display: flex; gap: 8px;") do
|
||||
link_to admin_user_path(user), class: "btn btn-sm" do
|
||||
render Primer::Beta::Octicon.new(icon: :eye, size: :small)
|
||||
end
|
||||
button_to admin_user_path(user), method: :delete, class: "btn btn-sm btn-danger", data: { turbo_confirm: "Delete user #{user.name || user.email} and all their uploads?" } do
|
||||
render Primer::Beta::Octicon.new(icon: :trash, size: :small)
|
||||
if user.is_admin?
|
||||
render Primer::Beta::Label.new(scheme: :accent) { "ADMIN" }
|
||||
end
|
||||
div(class: "d-flex gap-2") do
|
||||
render Primer::Beta::IconButton.new(
|
||||
icon: :eye,
|
||||
"aria-label": "View user",
|
||||
href: admin_user_path(user),
|
||||
tag: :a,
|
||||
size: :small
|
||||
)
|
||||
button_to admin_user_path(user), method: :delete, class: "d-inline", data: { turbo_confirm: "Delete user #{user.name || user.email} and all their uploads?" } do
|
||||
render Primer::Beta::IconButton.new(
|
||||
icon: :trash,
|
||||
"aria-label": "Delete user",
|
||||
scheme: :danger,
|
||||
size: :small,
|
||||
tag: :span
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -133,27 +135,25 @@ class Components::Admin::Search::Index < Components::Base
|
|||
|
||||
def uploads_section
|
||||
div do
|
||||
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do
|
||||
h2(class: "h4 mb-3") do
|
||||
plain "Uploads "
|
||||
span(style: "color: var(--fgColor-muted); font-weight: normal;") { "(#{@uploads.size})" }
|
||||
render Primer::Beta::Label.new(scheme: :secondary) { @uploads.size.to_s }
|
||||
end
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
@uploads.each do |upload|
|
||||
upload_row(upload)
|
||||
box.with_row do
|
||||
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upload_row(upload)
|
||||
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
|
||||
end
|
||||
|
||||
def empty_state
|
||||
div(style: "text-align: center; padding: 64px 24px; color: var(--fgColor-muted, #656d76);") do
|
||||
render Primer::Beta::Octicon.new(icon: :search, size: :medium)
|
||||
h2(style: "font-size: 20px; font-weight: 600; margin: 16px 0 8px;") { "No results found" }
|
||||
p(style: "margin: 0;") { "Try a different search query" }
|
||||
render Primer::Beta::Blankslate.new(border: true) do |component|
|
||||
component.with_visual_icon(icon: :search)
|
||||
component.with_heading(tag: :h2) { "No results found" }
|
||||
component.with_description { "Try a different search query" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class Components::Admin::Users::Show < Components::Base
|
|||
end
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 800px; margin: 0 auto; padding: 24px;") do
|
||||
div(class: "container-md p-4") do
|
||||
header_section
|
||||
stats_section
|
||||
api_keys_section
|
||||
|
|
@ -20,36 +20,37 @@ class Components::Admin::Users::Show < Components::Base
|
|||
private
|
||||
|
||||
def header_section
|
||||
header(style: "margin-bottom: 24px;") do
|
||||
div(style: "display: flex; justify-content: space-between; align-items: flex-start;") do
|
||||
div(class: "mb-4") do
|
||||
div(class: "d-flex flex-justify-between flex-items-start") do
|
||||
div do
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { @user.name || "Unnamed User" }
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
|
||||
div(class: "d-flex flex-items-center gap-2 mb-1") do
|
||||
h1(class: "h2 mb-0") { @user.name || "Unnamed User" }
|
||||
if @user.is_admin?
|
||||
render Primer::Beta::Label.new(scheme: :accent) { "ADMIN" }
|
||||
end
|
||||
end
|
||||
div(class: "color-fg-muted f5") do
|
||||
plain @user.email
|
||||
plain " · "
|
||||
code(style: "font-size: 12px;") { @user.public_id }
|
||||
code(class: "f6") { @user.public_id }
|
||||
end
|
||||
if @user.slack_id.present?
|
||||
p(style: "color: var(--fgColor-muted); margin: 4px 0 0; font-size: 12px;") do
|
||||
div(class: "color-fg-muted f6 mt-1") do
|
||||
plain "Slack: "
|
||||
code { @user.slack_id }
|
||||
end
|
||||
end
|
||||
end
|
||||
div(style: "display: flex; gap: 8px;") do
|
||||
if @user.is_admin?
|
||||
span(style: "background: #8250df; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;") { "ADMIN" }
|
||||
end
|
||||
link_to admin_search_path, class: "btn" do
|
||||
plain "Back to Search"
|
||||
end
|
||||
render Primer::Beta::Button.new(href: admin_search_path, tag: :a) do |button|
|
||||
button.with_leading_visual_icon(icon: :"arrow-left")
|
||||
plain "Back to Search"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stats_section
|
||||
div(style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px;") do
|
||||
div(class: "d-grid gap-3 mb-4", style: "grid-template-columns: repeat(3, 1fr);") do
|
||||
stat_card("Total Files", @user.total_files.to_s)
|
||||
stat_card("Total Storage", @user.total_storage_formatted)
|
||||
stat_card("Member Since", @user.created_at.strftime("%b %d, %Y"))
|
||||
|
|
@ -57,9 +58,11 @@ class Components::Admin::Users::Show < Components::Base
|
|||
end
|
||||
|
||||
def stat_card(label, value)
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; padding: 16px;") do
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted);") { label }
|
||||
div(style: "font-size: 24px; font-weight: 600; margin-top: 4px;") { value }
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
box.with_body(padding: :normal) do
|
||||
div(class: "f6 color-fg-muted") { label }
|
||||
div(class: "h3 mt-1") { value }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -67,30 +70,34 @@ class Components::Admin::Users::Show < Components::Base
|
|||
api_keys = @user.api_keys.recent
|
||||
return if api_keys.empty?
|
||||
|
||||
div(style: "margin-bottom: 24px;") do
|
||||
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "API Keys" }
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
div(class: "mb-4") do
|
||||
h2(class: "h4 mb-3") { "API Keys" }
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
api_keys.each do |api_key|
|
||||
api_key_row(api_key)
|
||||
box.with_row do
|
||||
api_key_row(api_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def api_key_row(api_key)
|
||||
div(style: "display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do
|
||||
div(class: "d-flex flex-justify-between flex-items-center") do
|
||||
div do
|
||||
div(style: "font-weight: 500;") { api_key.name }
|
||||
code(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.masked_token }
|
||||
div(class: "text-bold") { api_key.name }
|
||||
code(class: "f6 color-fg-muted") { api_key.masked_token }
|
||||
end
|
||||
div(style: "display: flex; align-items: center; gap: 12px;") do
|
||||
div(class: "d-flex flex-items-center gap-3") do
|
||||
if api_key.revoked?
|
||||
span(style: "background: #cf222e; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;") { "REVOKED" }
|
||||
render Primer::Beta::Label.new(scheme: :danger) { "REVOKED" }
|
||||
else
|
||||
span(style: "background: #1a7f37; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;") { "ACTIVE" }
|
||||
button_to "Revoke", helpers.admin_api_key_path(api_key), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Revoke this API key?" }
|
||||
render Primer::Beta::Label.new(scheme: :success) { "ACTIVE" }
|
||||
button_to helpers.admin_api_key_path(api_key), method: :delete, class: "d-inline", data: { confirm: "Revoke this API key?" } do
|
||||
render Primer::Beta::Button.new(scheme: :danger, size: :small, tag: :span) { "Revoke" }
|
||||
end
|
||||
end
|
||||
span(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.created_at.strftime("%b %d, %Y") }
|
||||
span(class: "f6 color-fg-muted") { api_key.created_at.strftime("%b %d, %Y") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -100,16 +107,14 @@ class Components::Admin::Users::Show < Components::Base
|
|||
return if uploads.empty?
|
||||
|
||||
div do
|
||||
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "Recent Uploads" }
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
h2(class: "h4 mb-3") { "Recent Uploads" }
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
uploads.each do |upload|
|
||||
upload_row(upload)
|
||||
box.with_row do
|
||||
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upload_row(upload)
|
||||
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,40 +3,30 @@
|
|||
class Components::APIKeys::Row < Components::Base
|
||||
include Phlex::Rails::Helpers::FormWith
|
||||
|
||||
def initialize(api_key:, index: 0)
|
||||
def initialize(api_key:)
|
||||
@api_key = api_key
|
||||
@index = index
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(
|
||||
style: "padding: 16px; #{index > 0 ? 'border-top: 1px solid var(--borderColor-default, #d0d7de);' : ''}",
|
||||
data: { api_key_id: api_key.id }
|
||||
) do
|
||||
div(style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 16px;") do
|
||||
div(style: "flex: 1; min-width: 0;") do
|
||||
div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do
|
||||
render Primer::Beta::Octicon.new(icon: :key, size: :small)
|
||||
div(style: "font-size: 14px; font-weight: 500;") do
|
||||
plain api_key.name
|
||||
end
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); font-family: monospace;") do
|
||||
plain api_key.masked_token
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do
|
||||
plain "Created #{time_ago_in_words(api_key.created_at)} ago"
|
||||
end
|
||||
div(class: "d-flex flex-justify-between flex-items-start gap-3") do
|
||||
div(class: "flex-1 min-width-0") do
|
||||
div(class: "d-flex flex-items-center gap-2 mb-1") do
|
||||
render Primer::Beta::Octicon.new(icon: :key, size: :small, color: :muted)
|
||||
span(class: "f5 text-bold") { api_key.name }
|
||||
end
|
||||
code(class: "f6 color-fg-muted") { api_key.masked_token }
|
||||
div(class: "f6 color-fg-muted mt-1") do
|
||||
plain "Created #{time_ago_in_words(api_key.created_at)} ago"
|
||||
end
|
||||
|
||||
render_revoke_dialog
|
||||
end
|
||||
|
||||
render_revoke_dialog
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key, :index
|
||||
attr_reader :api_key
|
||||
|
||||
def render_revoke_dialog
|
||||
render Primer::Alpha::Dialog.new(title: "Revoke API key?", size: :medium) do |dialog|
|
||||
|
|
@ -44,19 +34,17 @@ class Components::APIKeys::Row < Components::Base
|
|||
render Primer::Beta::Octicon.new(icon: :trash)
|
||||
end
|
||||
dialog.with_header(variant: :large) do
|
||||
h1(style: "margin: 0;") { "Revoke \"#{api_key.name}\"?" }
|
||||
h1(class: "h3") { "Revoke \"#{api_key.name}\"?" }
|
||||
end
|
||||
dialog.with_body do
|
||||
p(style: "margin: 0;") do
|
||||
p(class: "color-fg-muted") do
|
||||
plain "This action cannot be undone. Any applications using this API key will immediately lose access."
|
||||
end
|
||||
end
|
||||
dialog.with_footer do
|
||||
div(style: "display: flex; justify-content: flex-end; gap: 8px;") do
|
||||
form_with url: api_key_path(api_key), method: :delete, style: "display: inline;" do
|
||||
button(type: "submit", class: "btn btn-danger") do
|
||||
plain "Revoke key"
|
||||
end
|
||||
div(class: "d-flex flex-justify-end gap-2") do
|
||||
form_with url: api_key_path(api_key), method: :delete, class: "d-inline" do
|
||||
render Primer::Beta::Button.new(type: :submit, scheme: :danger) { "Revoke key" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class Components::APIKeys::Index < Components::Base
|
|||
end
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
div(class: "container-lg p-4") do
|
||||
header_section
|
||||
new_token_alert if new_token
|
||||
create_form
|
||||
|
|
@ -22,73 +22,47 @@ class Components::APIKeys::Index < Components::Base
|
|||
attr_reader :api_keys, :new_token
|
||||
|
||||
def header_section
|
||||
header(style: "margin-bottom: 24px;") do
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "API Keys" }
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
|
||||
div(class: "mb-4") do
|
||||
h1(class: "h2 mb-1") { "API Keys" }
|
||||
p(class: "color-fg-muted f5") do
|
||||
plain "Manage your API keys for programmatic access. "
|
||||
a(href: "/docs/api", style: "color: var(--fgColor-accent, #0969da);") { "View API documentation" }
|
||||
a(href: "/docs/api", class: "Link") { "View API documentation" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def new_token_alert
|
||||
div(
|
||||
style: "background: var(--bgColor-success-muted, #dafbe1); border: 1px solid var(--borderColor-success-emphasis, #1a7f37); border-radius: 6px; padding: 16px; margin-bottom: 24px;"
|
||||
) do
|
||||
div(style: "display: flex; align-items: flex-start; gap: 12px;") do
|
||||
render Primer::Beta::Octicon.new(icon: :check, size: :medium, color: :success)
|
||||
div(style: "flex: 1;") do
|
||||
p(style: "margin: 0 0 8px; font-weight: 600; color: var(--fgColor-success, #1a7f37);") do
|
||||
plain "API key created successfully!"
|
||||
end
|
||||
p(style: "margin: 0 0 8px; color: var(--fgColor-default, #1f2328); font-size: 14px;") do
|
||||
plain "Copy your API key now. You won't be able to see it again!"
|
||||
end
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; padding: 12px; font-family: monospace; font-size: 14px; word-break: break-all;") do
|
||||
plain new_token
|
||||
end
|
||||
end
|
||||
render Primer::Beta::Flash.new(scheme: :success, mb: 4) do |component|
|
||||
component.with_icon(icon: :check)
|
||||
div do
|
||||
p(class: "text-bold mb-1") { "API key created successfully!" }
|
||||
p(class: "mb-2") { "Copy your API key now. You won't be able to see it again!" }
|
||||
code(class: "d-block p-2 color-bg-subtle rounded-2 f5 text-mono") { new_token }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_form
|
||||
div(style: "background: linear-gradient(135deg, var(--bgColor-default, #fff) 0%, var(--bgColor-muted, #f6f8fa) 100%); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 12px; overflow: hidden; margin-bottom: 24px;") do
|
||||
# Header with icon
|
||||
div(style: "padding: 20px 24px; border-bottom: 1px solid var(--borderColor-muted, #d0d7de); display: flex; align-items: center; gap: 12px;") do
|
||||
div(style: "width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #0969da 0%, #0550ae 100%); display: flex; align-items: center; justify-content: center; color: #fff;") do
|
||||
render Primer::Beta::Octicon.new(icon: :key, size: :small)
|
||||
end
|
||||
div do
|
||||
h2(style: "font-size: 1.125rem; font-weight: 600; margin: 0; color: var(--fgColor-default, #1f2328);") { "Create new API key" }
|
||||
p(style: "font-size: 13px; color: var(--fgColor-muted, #656d76); margin: 2px 0 0;") { "Generate a key to access the CDN API programmatically" }
|
||||
end
|
||||
render Primer::Beta::BorderBox.new(mb: 4) do |box|
|
||||
box.with_header do
|
||||
h2(class: "f5 text-bold") { "Create new API key" }
|
||||
end
|
||||
|
||||
# Form body
|
||||
div(style: "padding: 24px;") do
|
||||
box.with_body do
|
||||
form_with url: api_keys_path, method: :post do
|
||||
div(style: "margin-bottom: 20px;") do
|
||||
label(for: "api_key_name", style: "display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--fgColor-default, #1f2328);") do
|
||||
plain "Key name"
|
||||
end
|
||||
p(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin: 0 0 8px;") do
|
||||
plain "Give your key a memorable name so you can identify it later"
|
||||
end
|
||||
div(class: "mb-3", style: "max-width: 400px;") do
|
||||
label(for: "api_key_name", class: "f5 text-bold d-block mb-2") { "Key name" }
|
||||
input(
|
||||
type: "text",
|
||||
name: "api_key[name]",
|
||||
id: "api_key_name",
|
||||
placeholder: "e.g., Production server, CI/CD pipeline, Local dev",
|
||||
placeholder: "e.g., The Coolest App That's Ever Lived",
|
||||
required: true,
|
||||
class: "form-control",
|
||||
style: "max-width: 400px; padding: 10px 12px; font-size: 14px;"
|
||||
class: "form-control width-full"
|
||||
)
|
||||
end
|
||||
|
||||
button(type: "submit", class: "btn btn-primary", style: "padding: 10px 20px; font-size: 14px; font-weight: 600;") do
|
||||
render Primer::Beta::Octicon.new(icon: :plus, mr: 2)
|
||||
plain "Generate API key"
|
||||
render Primer::Beta::Button.new(type: :submit, scheme: :primary) do |button|
|
||||
button.with_leading_visual_icon(icon: :key)
|
||||
plain "Create key"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -97,12 +71,14 @@ class Components::APIKeys::Index < Components::Base
|
|||
|
||||
def api_keys_list
|
||||
div do
|
||||
h2(style: "font-size: 1.25rem; font-weight: 600; margin: 0 0 16px;") { "Your API keys" }
|
||||
h2(class: "h4 mb-3") { "Your API keys" }
|
||||
|
||||
if api_keys.any?
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
api_keys.each_with_index do |api_key, index|
|
||||
render Components::APIKeys::Row.new(api_key: api_key, index: index)
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
api_keys.each do |api_key|
|
||||
box.with_row do
|
||||
render Components::APIKeys::Row.new(api_key: api_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
|
|
@ -112,10 +88,10 @@ class Components::APIKeys::Index < Components::Base
|
|||
end
|
||||
|
||||
def empty_state
|
||||
div(style: "text-align: center; padding: 64px 24px; color: var(--fgColor-muted, #656d76); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do
|
||||
render Primer::Beta::Octicon.new(icon: :key, size: :medium)
|
||||
h3(style: "font-size: 20px; font-weight: 600; margin: 16px 0 8px;") { "No API keys yet" }
|
||||
p(style: "margin: 0;") { "Create your first API key to get started with the API" }
|
||||
render Primer::Beta::Blankslate.new(border: true) do |component|
|
||||
component.with_visual_icon(icon: :key)
|
||||
component.with_heading(tag: :h3) { "No API keys yet" }
|
||||
component.with_description { "Create your first API key to get started with the API" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,73 +4,71 @@ class Components::Uploads::Row < Components::Base
|
|||
include Phlex::Rails::Helpers::FormWith
|
||||
include Phlex::Rails::Helpers::LinkTo
|
||||
|
||||
def initialize(upload:, index: 0, compact: false, admin: false)
|
||||
def initialize(upload:, compact: false, admin: false)
|
||||
@upload = upload
|
||||
@index = index
|
||||
@compact = compact
|
||||
@admin = admin
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(
|
||||
style: "padding: #{compact ? '12px 16px' : '16px'}; #{index > 0 ? 'border-top: 1px solid var(--borderColor-default, #d0d7de);' : ''}",
|
||||
data: { upload_id: upload.id }
|
||||
) do
|
||||
div(style: "display: flex; justify-content: space-between; align-items: #{compact ? 'center' : 'flex-start'}; gap: 16px;") do
|
||||
if compact
|
||||
compact_content
|
||||
else
|
||||
full_content
|
||||
end
|
||||
div(class: "d-flex flex-justify-between flex-items-#{compact ? 'center' : 'start'} gap-3") do
|
||||
if compact
|
||||
compact_content
|
||||
else
|
||||
full_content
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :upload, :index, :compact, :admin
|
||||
attr_reader :upload, :compact, :admin
|
||||
|
||||
def compact_content
|
||||
div(style: "flex: 1; min-width: 0;") do
|
||||
div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do
|
||||
div(class: "flex-1 min-width-0") do
|
||||
div(class: "f5 text-bold text-truncate") do
|
||||
render Primer::Beta::Octicon.new(icon: file_icon_for(upload.content_type), size: :small, mr: 1)
|
||||
plain upload.filename.to_s
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do
|
||||
div(class: "f6 color-fg-muted mt-1") do
|
||||
plain "#{upload.human_file_size} • #{time_ago_in_words(upload.created_at)} ago"
|
||||
end
|
||||
end
|
||||
|
||||
div(style: "display: flex; gap: 8px; align-items: center;") do
|
||||
a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do
|
||||
plain "View"
|
||||
end
|
||||
|
||||
div(class: "d-flex gap-2 flex-items-center") do
|
||||
render Primer::Beta::Button.new(
|
||||
href: upload.cdn_url,
|
||||
tag: :a,
|
||||
size: :small,
|
||||
target: "_blank",
|
||||
rel: "noopener"
|
||||
) { "View" }
|
||||
|
||||
render_delete_dialog
|
||||
end
|
||||
end
|
||||
|
||||
def full_content
|
||||
div(style: "flex: 1; min-width: 0;") do
|
||||
div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do
|
||||
div(class: "flex-1 min-width-0") do
|
||||
div(class: "d-flex flex-items-center gap-2 mb-1") do
|
||||
render Primer::Beta::Octicon.new(icon: file_icon_for(upload.content_type), size: :small)
|
||||
div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do
|
||||
plain upload.filename.to_s
|
||||
end
|
||||
span(
|
||||
style: "font-size: 12px; padding: 2px 8px; background: var(--bgColor-muted, #f6f8fa); border-radius: 12px; color: var(--fgColor-muted, #656d76);"
|
||||
) do
|
||||
plain upload.provenance.titleize
|
||||
end
|
||||
span(class: "f5 text-bold text-truncate") { upload.filename.to_s }
|
||||
render Primer::Beta::Label.new(scheme: :secondary, size: :small) { upload.provenance.titleize }
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") do
|
||||
div(class: "f6 color-fg-muted") do
|
||||
plain "#{upload.human_file_size} • #{upload.content_type} • #{time_ago_in_words(upload.created_at)} ago"
|
||||
end
|
||||
end
|
||||
|
||||
div(style: "display: flex; gap: 8px; align-items: center;") do
|
||||
a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do
|
||||
render Primer::Beta::Octicon.new(icon: :link, mr: 1)
|
||||
div(class: "d-flex gap-2 flex-items-center") do
|
||||
render Primer::Beta::Button.new(
|
||||
href: upload.cdn_url,
|
||||
tag: :a,
|
||||
size: :small,
|
||||
target: "_blank",
|
||||
rel: "noopener"
|
||||
) do |button|
|
||||
button.with_leading_visual_icon(icon: :link)
|
||||
plain "View"
|
||||
end
|
||||
|
||||
|
|
@ -84,19 +82,17 @@ class Components::Uploads::Row < Components::Base
|
|||
render Primer::Beta::Octicon.new(icon: :trash)
|
||||
end
|
||||
dialog.with_header(variant: :large) do
|
||||
h1(style: "margin: 0;") { "Delete #{upload.filename}?" }
|
||||
h1(class: "h3") { "Delete #{upload.filename}?" }
|
||||
end
|
||||
dialog.with_body do
|
||||
p(style: "margin: 0;") do
|
||||
p(class: "color-fg-muted") do
|
||||
plain "This action cannot be undone. The file will be permanently removed from the CDN."
|
||||
end
|
||||
end
|
||||
dialog.with_footer do
|
||||
div(style: "display: flex; justify-content: flex-end; gap: 8px;") do
|
||||
form_with url: (admin ? admin_upload_path(upload) : upload_path(upload)), method: :delete, style: "display: inline;" do
|
||||
button(type: "submit", class: "btn btn-danger") do
|
||||
plain "Delete"
|
||||
end
|
||||
div(class: "d-flex flex-justify-end gap-2") do
|
||||
form_with url: (admin ? admin_upload_path(upload) : upload_path(upload)), method: :delete, class: "d-inline" do
|
||||
render Primer::Beta::Button.new(type: :submit, scheme: :danger) { "Delete" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Components::Uploads::Index < Components::Base
|
|||
end
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
div(class: "container-lg p-4") do
|
||||
header_section
|
||||
search_section
|
||||
uploads_list
|
||||
|
|
@ -25,35 +25,35 @@ class Components::Uploads::Index < Components::Base
|
|||
attr_reader :uploads, :query
|
||||
|
||||
def header_section
|
||||
header(style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;") do
|
||||
div(class: "d-flex flex-justify-between flex-items-center mb-4") do
|
||||
div do
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Your Uploads" }
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
|
||||
h1(class: "h2 mb-1") { "Your Uploads" }
|
||||
p(class: "color-fg-muted f5 mb-0") do
|
||||
count = uploads.respond_to?(:total_count) ? uploads.total_count : uploads.size
|
||||
plain "#{count} file#{count == 1 ? '' : 's'}"
|
||||
end
|
||||
end
|
||||
|
||||
link_to new_upload_path, class: "btn btn-primary" do
|
||||
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
|
||||
render Primer::Beta::Button.new(href: new_upload_path, tag: :a, scheme: :primary) do |button|
|
||||
button.with_leading_visual_icon(icon: :upload)
|
||||
plain "Upload File"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def search_section
|
||||
div(style: "margin-bottom: 24px;") do
|
||||
form_with url: uploads_path, method: :get, style: "display: flex; gap: 8px;" do
|
||||
div(class: "mb-4") do
|
||||
form_with url: uploads_path, method: :get, class: "d-flex gap-2" do
|
||||
input(
|
||||
type: "search",
|
||||
name: "query",
|
||||
placeholder: "Search files...",
|
||||
value: query,
|
||||
class: "form-control",
|
||||
style: "flex: 1; max-width: 400px;"
|
||||
class: "form-control flex-1",
|
||||
style: "max-width: 400px;"
|
||||
)
|
||||
button(type: "submit", class: "btn") do
|
||||
render Primer::Beta::Octicon.new(icon: :search, mr: 1)
|
||||
render Primer::Beta::Button.new(type: :submit) do |button|
|
||||
button.with_leading_visual_icon(icon: :search)
|
||||
plain "Search"
|
||||
end
|
||||
end
|
||||
|
|
@ -62,9 +62,11 @@ class Components::Uploads::Index < Components::Base
|
|||
|
||||
def uploads_list
|
||||
if uploads.any?
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
uploads.each_with_index do |upload, index|
|
||||
render Components::Uploads::Row.new(upload: upload, index: index, compact: false)
|
||||
render Primer::Beta::BorderBox.new do |box|
|
||||
uploads.each do |upload|
|
||||
box.with_row do
|
||||
render Components::Uploads::Row.new(upload: upload, compact: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
|
|
@ -73,16 +75,16 @@ class Components::Uploads::Index < Components::Base
|
|||
end
|
||||
|
||||
def empty_state
|
||||
div(style: "text-align: center; padding: 64px 24px; color: var(--fgColor-muted, #656d76);") do
|
||||
render Primer::Beta::Octicon.new(icon: :inbox, size: :medium)
|
||||
h2(style: "font-size: 20px; font-weight: 600; margin: 16px 0 8px;") do
|
||||
render Primer::Beta::Blankslate.new(border: true) do |component|
|
||||
component.with_visual_icon(icon: :inbox)
|
||||
component.with_heading(tag: :h2) do
|
||||
query.present? ? "No files found" : "No uploads yet"
|
||||
end
|
||||
p(style: "margin: 0 0 24px;") do
|
||||
component.with_description do
|
||||
query.present? ? "Try a different search query" : "Upload your first file to get started"
|
||||
end
|
||||
unless query.present?
|
||||
link_to new_upload_path, class: "btn btn-primary" do
|
||||
component.with_primary_action(href: new_upload_path) do
|
||||
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
|
||||
plain "Upload File"
|
||||
end
|
||||
|
|
@ -91,7 +93,7 @@ class Components::Uploads::Index < Components::Base
|
|||
end
|
||||
|
||||
def pagination_section
|
||||
div(style: "margin-top: 24px; text-align: center;") do
|
||||
div(class: "mt-4 text-center") do
|
||||
paginate uploads
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Components::Uploads::New < Components::Base
|
|||
include Phlex::Rails::Helpers::LinkTo
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
div(class: "container-lg p-4") do
|
||||
header_section
|
||||
upload_form
|
||||
end
|
||||
|
|
@ -14,14 +14,14 @@ class Components::Uploads::New < Components::Base
|
|||
private
|
||||
|
||||
def header_section
|
||||
header(style: "margin-bottom: 32px;") do
|
||||
div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px;") do
|
||||
link_to uploads_path, style: "color: var(--fgColor-muted, #656d76); text-decoration: none;" do
|
||||
div(class: "mb-4") do
|
||||
div(class: "d-flex flex-items-center gap-2 mb-2") do
|
||||
render Primer::Beta::Button.new(href: uploads_path, tag: :a, scheme: :invisible, size: :small) do
|
||||
render Primer::Beta::Octicon.new(icon: :"arrow-left")
|
||||
end
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Upload File" }
|
||||
h1(class: "h2 mb-0") { "Upload File" }
|
||||
end
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 0; font-size: 14px;") do
|
||||
p(class: "color-fg-muted f5 mb-0") do
|
||||
plain "Drop a file anywhere on this page or click to browse"
|
||||
end
|
||||
end
|
||||
|
|
@ -29,22 +29,21 @@ class Components::Uploads::New < Components::Base
|
|||
|
||||
def upload_form
|
||||
form_with url: uploads_path, method: :post, multipart: true, data: { dropzone_form: true } do
|
||||
# Main upload area - drag anywhere on page for full-screen overlay
|
||||
div(
|
||||
class: "upload-area",
|
||||
style: upload_area_styles
|
||||
class: "upload-area rounded-3 p-6 text-center",
|
||||
style: "border: 3px dashed var(--borderColor-default); transition: all 0.2s ease;"
|
||||
) do
|
||||
div(style: "text-align: center;") do
|
||||
render Primer::Beta::Octicon.new(icon: :upload, size: :medium)
|
||||
h2(style: "font-size: 32px; font-weight: 600; margin: 24px 0 16px;") { "Drag & Drop" }
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 0 0 32px; font-size: 16px;") do
|
||||
div(class: "py-6") do
|
||||
render Primer::Beta::Octicon.new(icon: :upload, size: :medium, color: :muted)
|
||||
h2(class: "h1 mt-4 mb-3") { "Drag & Drop" }
|
||||
p(class: "color-fg-muted f4 mb-4") do
|
||||
plain "Drop a file anywhere on this page to upload instantly"
|
||||
end
|
||||
|
||||
label(
|
||||
for: "file-input",
|
||||
class: "btn btn-primary btn-large",
|
||||
style: "cursor: pointer; display: inline-block; font-size: 16px; padding: 12px 24px;"
|
||||
style: "cursor: pointer;"
|
||||
) do
|
||||
render Primer::Beta::Octicon.new(icon: :file, mr: 2)
|
||||
plain "Choose File"
|
||||
|
|
@ -55,32 +54,24 @@ class Components::Uploads::New < Components::Base
|
|||
name: "file",
|
||||
id: "file-input",
|
||||
data: { dropzone_input: true },
|
||||
style: "display: none;"
|
||||
class: "d-none"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Tips section
|
||||
div(style: "margin-top: 48px; padding: 24px; background: var(--bgColor-muted, #f6f8fa); border-radius: 8px;") do
|
||||
h3(style: "font-size: 16px; font-weight: 600; margin: 0 0 16px;") { "How it works" }
|
||||
ul(style: "margin: 0; padding-left: 24px; font-size: 14px; color: var(--fgColor-muted, #656d76); line-height: 1.8;") do
|
||||
li { "Drag and drop a file anywhere on this page for instant upload" }
|
||||
li { "Or click the button above to browse and select a file" }
|
||||
li { "Files are stored securely and accessible via CDN URLs" }
|
||||
li { "Supports images, videos, documents, and more" }
|
||||
render Primer::Beta::BorderBox.new(mt: 5) do |box|
|
||||
box.with_header do
|
||||
h3(class: "f5 text-bold") { "How it works" }
|
||||
end
|
||||
box.with_body do
|
||||
ul(class: "color-fg-muted f5 pl-4 mb-0", style: "line-height: 1.8;") do
|
||||
li { "Drag and drop a file anywhere on this page for instant upload" }
|
||||
li { "Or click the button above to browse and select a file" }
|
||||
li { "Files are stored securely and accessible via CDN URLs" }
|
||||
li { "Supports images, videos, documents, and more" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upload_area_styles
|
||||
<<~CSS.strip
|
||||
border: 3px dashed var(--borderColor-default, #d0d7de);
|
||||
border-radius: 16px;
|
||||
padding: 96px 48px;
|
||||
background: var(--bgColor-default, #fff);
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
CSS
|
||||
end
|
||||
end
|
||||
|
|
|
|||
22
app/controllers/api/v4/api_keys_controller.rb
Normal file
22
app/controllers/api/v4/api_keys_controller.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module API
|
||||
module V4
|
||||
class APIKeysController < ApplicationController
|
||||
def revoke
|
||||
api_key = current_token
|
||||
owner_email = current_user.email
|
||||
key_name = api_key.name
|
||||
|
||||
api_key.revoke!
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
owner_email: owner_email,
|
||||
key_name: key_name,
|
||||
status: "complete"
|
||||
}, status: :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -22,6 +22,7 @@ Rails.application.routes.draw do
|
|||
get "me", to: "users#show"
|
||||
post "upload", to: "uploads#create"
|
||||
post "upload_from_url", to: "uploads#create_from_url"
|
||||
post "revoke", to: "api_keys#revoke"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue