mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 19:45:07 +00:00
wrow
This commit is contained in:
parent
5ce2ea55ad
commit
be95fac10a
29 changed files with 898 additions and 24 deletions
4
Gemfile
4
Gemfile
|
|
@ -62,3 +62,7 @@ gem "omniauth-hack_club"
|
|||
gem "faraday"
|
||||
gem "pundit"
|
||||
gem "primer_view_components"
|
||||
gem "pg_search"
|
||||
gem "kaminari"
|
||||
gem "high_voltage"
|
||||
gem "redcarpet"
|
||||
|
|
|
|||
21
Gemfile.lock
21
Gemfile.lock
|
|
@ -129,6 +129,7 @@ GEM
|
|||
hashids (1.0.6)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
high_voltage (5.0.0)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
|
|
@ -140,6 +141,18 @@ GEM
|
|||
json (2.18.0)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
kaminari-activerecord (= 1.2.2)
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-actionview (1.2.2)
|
||||
actionview
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-activerecord (1.2.2)
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
|
|
@ -220,6 +233,9 @@ GEM
|
|||
pg (1.6.3-x86_64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pg_search (2.3.7)
|
||||
activerecord (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
phlex (2.4.0)
|
||||
refract (~> 1.0)
|
||||
zeitwerk (~> 2.7)
|
||||
|
|
@ -306,6 +322,7 @@ GEM
|
|||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
refract (1.1.0)
|
||||
prism
|
||||
zeitwerk
|
||||
|
|
@ -434,10 +451,13 @@ DEPENDENCIES
|
|||
dotenv-rails
|
||||
faraday
|
||||
hashid-rails
|
||||
high_voltage
|
||||
jb
|
||||
kaminari
|
||||
omniauth
|
||||
omniauth-hack_club
|
||||
pg (~> 1.3)
|
||||
pg_search
|
||||
phlex-rails
|
||||
primer_view_components
|
||||
propshaft
|
||||
|
|
@ -445,6 +465,7 @@ DEPENDENCIES
|
|||
puma (>= 5.0)
|
||||
pundit
|
||||
rails (~> 8.0.4)
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
solid_cable
|
||||
|
|
|
|||
32
app/components/docs/content.rb
Normal file
32
app/components/docs/content.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Docs::Content < Components::Base
|
||||
def initialize(doc:)
|
||||
@doc = doc
|
||||
end
|
||||
|
||||
def view_template
|
||||
style do
|
||||
raw(<<~CSS.html_safe)
|
||||
.markdown-body h1 { font-size: 2em; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--borderColor-default); }
|
||||
.markdown-body h2 { font-size: 1.5em; margin-top: 24px; margin-bottom: 16px; }
|
||||
.markdown-body h3 { font-size: 1.25em; margin-top: 24px; margin-bottom: 16px; }
|
||||
.markdown-body p { margin-bottom: 16px; line-height: 1.6; }
|
||||
.markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; }
|
||||
.markdown-body li { margin-bottom: 4px; }
|
||||
.markdown-body code { background: var(--bgColor-muted); padding: 2px 6px; border-radius: 4px; font-size: 85%; }
|
||||
.markdown-body pre { background: var(--bgColor-muted); padding: 16px; border-radius: 6px; overflow-x: auto; margin-bottom: 16px; }
|
||||
.markdown-body pre code { background: none; padding: 0; }
|
||||
.markdown-body a { color: var(--fgColor-accent); }
|
||||
.markdown-body blockquote { padding: 0 1em; color: var(--fgColor-muted); border-left: 4px solid var(--borderColor-default); margin-bottom: 16px; }
|
||||
.markdown-body table { border-collapse: collapse; margin-bottom: 16px; width: 100%; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid var(--borderColor-default); padding: 8px 12px; }
|
||||
.markdown-body th { background: var(--bgColor-muted); }
|
||||
CSS
|
||||
end
|
||||
|
||||
article(class: "markdown-body") do
|
||||
raw @doc.content.html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
17
app/components/docs/page.rb
Normal file
17
app/components/docs/page.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Docs::Page < Components::Base
|
||||
def initialize(doc:, docs:)
|
||||
@doc = doc
|
||||
@docs = docs
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(class: "d-flex", style: "min-height: calc(100vh - 64px);") do
|
||||
render Components::Docs::Sidebar.new(docs: @docs, current_doc: @doc)
|
||||
main(class: "flex-auto p-4 p-md-5", style: "max-width: 900px;") do
|
||||
render Components::Docs::Content.new(doc: @doc)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
36
app/components/docs/sidebar.rb
Normal file
36
app/components/docs/sidebar.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Docs::Sidebar < Components::Base
|
||||
def initialize(docs:, current_doc:)
|
||||
@docs = docs
|
||||
@current_doc = current_doc
|
||||
end
|
||||
|
||||
def view_template
|
||||
aside(
|
||||
class: "color-bg-subtle border-right",
|
||||
style: "width: 280px; min-width: 280px; padding: 24px 16px;"
|
||||
) do
|
||||
div(class: "mb-3") do
|
||||
a(href: root_path, class: "color-fg-muted text-small d-flex flex-items-center") do
|
||||
render Primer::Beta::Octicon.new(icon: "arrow-left", size: :small, mr: 1)
|
||||
plain "Back to CDN"
|
||||
end
|
||||
end
|
||||
|
||||
h2(class: "h5 mb-3") { "Documentation" }
|
||||
|
||||
render Primer::Beta::NavList.new(aria: { label: "Documentation" }) do |nav|
|
||||
@docs.each do |doc|
|
||||
nav.with_item(
|
||||
label: doc.title,
|
||||
href: doc_path(doc.id),
|
||||
selected: doc.id == @current_doc.id
|
||||
) do |item|
|
||||
item.with_leading_visual_icon(icon: doc.icon)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,10 +7,16 @@ class Components::HeaderBar < Components::Base
|
|||
def view_template
|
||||
header(class: "app-header", style: "display: flex; align-items: center; justify-content: space-between;") do
|
||||
div(style: "display: flex; align-items: center; gap: 1rem;") do
|
||||
span(class: "app-header-brand") do
|
||||
a(href: root_path, class: "app-header-brand", style: "text-decoration: none; color: inherit;") do
|
||||
plain "Hack Club CDN"
|
||||
sup(class: "app-header-env-badge") { "(dev)" } if Rails.env.development?
|
||||
end
|
||||
nav(style: "display: flex; gap: 1rem; margin-left: 1rem;") do
|
||||
if signed_in?
|
||||
a(href: uploads_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Uploads" }
|
||||
end
|
||||
a(href: doc_path("getting-started"), style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Docs" }
|
||||
end
|
||||
end
|
||||
|
||||
return unless signed_in?
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class Components::StaticPages::Base < Components::Base
|
|||
|
||||
def resources_panel
|
||||
links = [
|
||||
{ label: "API Docs", href: "https://github.com/hackclub/cdn#-api-usage", icon: :book },
|
||||
{ label: "Documentation", href: doc_path("getting-started"), icon: :book },
|
||||
{ label: "API Docs", href: "https://github.com/hackclub/cdn#-api-usage", icon: :code },
|
||||
{ label: "GitHub Repo", href: "https://github.com/hackclub/cdn", icon: :"mark-github" },
|
||||
{ label: "Use via Slack", href: "https://hackclub.enterprise.slack.com/archives/C016DEDUL87", icon: :"comment-discussion" },
|
||||
{ label: "Help with development?", href: "https://hackclub.enterprise.slack.com/archives/C0ACGUA6XTJ", icon: :heart },
|
||||
|
|
|
|||
|
|
@ -63,22 +63,7 @@ class Components::StaticPages::Home < Components::StaticPages::Base
|
|||
def recent_uploads_list
|
||||
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
|
||||
stats[:recent_uploads].each_with_index do |upload, index|
|
||||
div(style: "padding: 12px 16px; #{index > 0 ? 'border-top: 1px solid var(--borderColor-default, #d0d7de);' : ''}") do
|
||||
div(style: "display: flex; justify-content: space-between; align-items: center; gap: 16px;") do
|
||||
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
|
||||
render Primer::Beta::Octicon.new(icon: :file, size: :small, mr: 1)
|
||||
plain upload.filename.to_s
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do
|
||||
plain "#{upload.human_file_size} • #{time_ago_in_words(upload.created_at)} ago"
|
||||
end
|
||||
end
|
||||
a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do
|
||||
plain "View"
|
||||
end
|
||||
end
|
||||
end
|
||||
render Components::Uploads::Row.new(upload: upload, index: index, compact: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
123
app/components/uploads/_row.rb
Normal file
123
app/components/uploads/_row.rb
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Uploads::Row < Components::Base
|
||||
include Phlex::Rails::Helpers::FormWith
|
||||
include Phlex::Rails::Helpers::LinkTo
|
||||
|
||||
def initialize(upload:, index: 0, compact: false)
|
||||
@upload = upload
|
||||
@index = index
|
||||
@compact = compact
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :upload, :index, :compact
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
end
|
||||
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") 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)
|
||||
plain "View"
|
||||
end
|
||||
|
||||
render_delete_dialog
|
||||
end
|
||||
end
|
||||
|
||||
def render_delete_dialog
|
||||
render Primer::Alpha::Dialog.new(title: "Delete file?", size: :medium) do |dialog|
|
||||
dialog.with_show_button(scheme: :danger, size: :small) do
|
||||
render Primer::Beta::Octicon.new(icon: :trash)
|
||||
end
|
||||
dialog.with_header(variant: :large) do
|
||||
h1(style: "margin: 0;") { "Delete #{upload.filename}?" }
|
||||
end
|
||||
dialog.with_body do
|
||||
p(style: "margin: 0;") 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: upload_path(upload), method: :delete, style: "display: inline;" do
|
||||
button(type: "submit", class: "btn btn-danger") do
|
||||
plain "Delete"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def file_icon_for(content_type)
|
||||
case content_type
|
||||
when /image/
|
||||
:image
|
||||
when /video/
|
||||
:video
|
||||
when /audio/
|
||||
:unmute
|
||||
when /pdf/
|
||||
:file
|
||||
when /zip|rar|tar|gz/
|
||||
:"file-zip"
|
||||
when /text|json|xml/
|
||||
:code
|
||||
else
|
||||
:file
|
||||
end
|
||||
end
|
||||
end
|
||||
98
app/components/uploads/index.rb
Normal file
98
app/components/uploads/index.rb
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Uploads::Index < Components::Base
|
||||
include Phlex::Rails::Helpers::FormWith
|
||||
include Phlex::Rails::Helpers::LinkTo
|
||||
|
||||
register_output_helper :paginate
|
||||
|
||||
def initialize(uploads:, query: nil)
|
||||
@uploads = uploads
|
||||
@query = query
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
header_section
|
||||
search_section
|
||||
uploads_list
|
||||
pagination_section if uploads.respond_to?(:total_pages) && uploads.total_pages > 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :uploads, :query
|
||||
|
||||
def header_section
|
||||
header(style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;") 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
|
||||
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)
|
||||
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
|
||||
input(
|
||||
type: "search",
|
||||
name: "query",
|
||||
placeholder: "Search files...",
|
||||
value: query,
|
||||
class: "form-control",
|
||||
style: "flex: 1; max-width: 400px;"
|
||||
)
|
||||
button(type: "submit", class: "btn") do
|
||||
render Primer::Beta::Octicon.new(icon: :search, mr: 1)
|
||||
plain "Search"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
else
|
||||
empty_state
|
||||
end
|
||||
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
|
||||
query.present? ? "No files found" : "No uploads yet"
|
||||
end
|
||||
p(style: "margin: 0 0 24px;") 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
|
||||
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
|
||||
plain "Upload File"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def pagination_section
|
||||
div(style: "margin-top: 24px; text-align: center;") do
|
||||
paginate uploads
|
||||
end
|
||||
end
|
||||
end
|
||||
86
app/components/uploads/new.rb
Normal file
86
app/components/uploads/new.rb
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Components::Uploads::New < Components::Base
|
||||
include Phlex::Rails::Helpers::FormWith
|
||||
include Phlex::Rails::Helpers::LinkTo
|
||||
|
||||
def view_template
|
||||
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
|
||||
header_section
|
||||
upload_form
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
render Primer::Beta::Octicon.new(icon: :"arrow-left")
|
||||
end
|
||||
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Upload File" }
|
||||
end
|
||||
p(style: "color: var(--fgColor-muted, #656d76); margin: 0; font-size: 14px;") do
|
||||
plain "Drop a file anywhere on this page or click to browse"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
) 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
|
||||
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;"
|
||||
) do
|
||||
render Primer::Beta::Octicon.new(icon: :file, mr: 2)
|
||||
plain "Choose File"
|
||||
end
|
||||
|
||||
input(
|
||||
type: "file",
|
||||
name: "file",
|
||||
id: "file-input",
|
||||
data: { dropzone_input: true },
|
||||
style: "display: 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" }
|
||||
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
|
||||
23
app/controllers/docs_controller.rb
Normal file
23
app/controllers/docs_controller.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DocsController < ApplicationController
|
||||
include HighVoltage::StaticPage
|
||||
|
||||
skip_before_action :require_authentication!
|
||||
|
||||
before_action :load_docs_navigation
|
||||
|
||||
def show
|
||||
@doc = DocPage.find(params[:id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_docs_navigation
|
||||
@docs = DocPage.all
|
||||
end
|
||||
|
||||
def page_finder_factory
|
||||
DocPage
|
||||
end
|
||||
end
|
||||
57
app/controllers/uploads_controller.rb
Normal file
57
app/controllers/uploads_controller.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class UploadsController < ApplicationController
|
||||
before_action :set_upload, only: [:destroy]
|
||||
|
||||
def index
|
||||
@uploads = current_user.uploads.recent
|
||||
|
||||
if params[:query].present?
|
||||
@uploads = @uploads.search_by_filename(params[:query])
|
||||
end
|
||||
|
||||
@uploads = @uploads.page(params[:page]).per(50)
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
uploaded_file = params[:file]
|
||||
|
||||
if uploaded_file.blank?
|
||||
redirect_to new_upload_path, alert: "Please select a file to upload."
|
||||
return
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: uploaded_file.tempfile,
|
||||
filename: uploaded_file.original_filename,
|
||||
content_type: uploaded_file.content_type
|
||||
)
|
||||
|
||||
@upload = current_user.uploads.create!(
|
||||
blob: blob,
|
||||
provenance: :web
|
||||
)
|
||||
|
||||
redirect_to uploads_path, notice: "File uploaded successfully!"
|
||||
rescue StandardError => e
|
||||
redirect_to new_upload_path, alert: "Upload failed: #{e.message}"
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @upload
|
||||
|
||||
@upload.destroy!
|
||||
redirect_to uploads_path, notice: "Upload deleted successfully."
|
||||
rescue Pundit::NotAuthorizedError
|
||||
redirect_to uploads_path, alert: "You are not authorized to delete this upload."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_upload
|
||||
@upload = Upload.find(params[:id])
|
||||
end
|
||||
end
|
||||
105
app/frontend/controllers/upload_dropzone.js
Normal file
105
app/frontend/controllers/upload_dropzone.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
(function() {
|
||||
let dropzone;
|
||||
let counter = 0;
|
||||
let fileInput, form;
|
||||
|
||||
function init() {
|
||||
const formElement = document.querySelector("[data-dropzone-form]");
|
||||
if (!formElement) {
|
||||
fileInput = null;
|
||||
form = null;
|
||||
return;
|
||||
}
|
||||
|
||||
form = formElement;
|
||||
fileInput = form.querySelector("[data-dropzone-input]");
|
||||
|
||||
if (!fileInput) return;
|
||||
|
||||
// Handle file input change
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Auto-submit on file selection
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent default drag behaviors
|
||||
document.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Show overlay when dragging enters window
|
||||
document.addEventListener("dragenter", (e) => {
|
||||
if (!fileInput) return;
|
||||
e.preventDefault();
|
||||
if (counter === 0) {
|
||||
showDropzone();
|
||||
}
|
||||
counter++;
|
||||
});
|
||||
|
||||
// Hide overlay when dragging leaves window
|
||||
document.addEventListener("dragleave", (e) => {
|
||||
if (!fileInput) return;
|
||||
e.preventDefault();
|
||||
counter--;
|
||||
if (counter === 0) {
|
||||
hideDropzone();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle file drop
|
||||
document.addEventListener("drop", (e) => {
|
||||
if (!fileInput) return;
|
||||
e.preventDefault();
|
||||
counter = 0;
|
||||
hideDropzone();
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
// Auto-submit on drop
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Show full-screen dropzone overlay
|
||||
function showDropzone() {
|
||||
if (!dropzone) {
|
||||
dropzone = document.createElement("div");
|
||||
dropzone.classList.add("file-dropzone");
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.innerText = "Drop your file here";
|
||||
dropzone.appendChild(title);
|
||||
|
||||
document.body.appendChild(dropzone);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Force reflow for transition
|
||||
void dropzone.offsetWidth;
|
||||
|
||||
dropzone.classList.add("visible");
|
||||
}
|
||||
}
|
||||
|
||||
// Hide full-screen dropzone overlay
|
||||
function hideDropzone() {
|
||||
if (dropzone) {
|
||||
dropzone.remove();
|
||||
dropzone = null;
|
||||
document.body.style.overflow = "auto";
|
||||
counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -2,3 +2,4 @@ import Rails from "@rails/ujs";
|
|||
Rails.start();
|
||||
import "@primer/view-components/app/components/primer/primer.js";
|
||||
|
||||
import "../controllers/upload_dropzone.js";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
@use "@/styles/dark_mode";
|
||||
@use "@/styles/hca";
|
||||
@use "@/styles/admin_tool";
|
||||
@use "@/styles/file_dropzone";
|
||||
|
||||
.app-header {
|
||||
position: static;
|
||||
|
|
|
|||
99
app/frontend/styles/file_dropzone.scss
Normal file
99
app/frontend/styles/file_dropzone.scss
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
|
||||
.file-dropzone {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Animated dashed border using gradient technique
|
||||
background-image:
|
||||
linear-gradient(90deg, #0969da 50%, transparent 50%),
|
||||
linear-gradient(90deg, #0969da 50%, transparent 50%),
|
||||
linear-gradient(0deg, #0969da 50%, transparent 50%),
|
||||
linear-gradient(0deg, #0969da 50%, transparent 50%);
|
||||
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
|
||||
background-size:
|
||||
50px 8px,
|
||||
50px 8px,
|
||||
8px 50px,
|
||||
8px 50px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 100%,
|
||||
0 100%,
|
||||
100% 20px;
|
||||
animation: border-dance 1s infinite linear;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
color: #0969da;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
transition-property: transform;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.file-dropzone.visible {
|
||||
opacity: 1;
|
||||
|
||||
h1 {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-dance {
|
||||
0% {
|
||||
background-position:
|
||||
0 0,
|
||||
0 100%,
|
||||
0 100%,
|
||||
100% 20px;
|
||||
}
|
||||
100% {
|
||||
background-position:
|
||||
-50px 0,
|
||||
50px 100%,
|
||||
0 calc(100% + 50px),
|
||||
100% -30px;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-dropzone {
|
||||
background-color: rgba(13, 17, 23, 0.95);
|
||||
|
||||
h1 {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
background-image:
|
||||
linear-gradient(90deg, #58a6ff 50%, transparent 50%),
|
||||
linear-gradient(90deg, #58a6ff 50%, transparent 50%),
|
||||
linear-gradient(0deg, #58a6ff 50%, transparent 50%),
|
||||
linear-gradient(0deg, #58a6ff 50%, transparent 50%);
|
||||
}
|
||||
}
|
||||
79
app/models/doc_page.rb
Normal file
79
app/models/doc_page.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DocPage
|
||||
DOCS_PATH = Rails.root.join("app/views/docs/pages")
|
||||
|
||||
attr_reader :id, :title, :icon, :order, :content
|
||||
|
||||
def initialize(id:, title:, icon:, order:, content:)
|
||||
@id = id
|
||||
@title = title
|
||||
@icon = icon
|
||||
@order = order
|
||||
@content = content
|
||||
end
|
||||
|
||||
class << self
|
||||
def all
|
||||
@all ||= load_all_docs.sort_by(&:order)
|
||||
end
|
||||
|
||||
def find(id)
|
||||
all.find { |doc| doc.id == id } || raise(ActiveRecord::RecordNotFound, "Doc '#{id}' not found")
|
||||
end
|
||||
|
||||
def reload!
|
||||
@all = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_all_docs
|
||||
Dir.glob(DOCS_PATH.join("*.md")).map do |file|
|
||||
parse_doc_file(file)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_doc_file(file)
|
||||
id = File.basename(file, ".md")
|
||||
raw_content = File.read(file)
|
||||
frontmatter, content = extract_frontmatter(raw_content)
|
||||
|
||||
new(
|
||||
id: id,
|
||||
title: frontmatter["title"] || id.titleize,
|
||||
icon: (frontmatter["icon"] || "file").to_sym,
|
||||
order: frontmatter["order"] || 999,
|
||||
content: render_markdown(content)
|
||||
)
|
||||
end
|
||||
|
||||
def extract_frontmatter(content)
|
||||
if content.start_with?("---")
|
||||
parts = content.split("---", 3)
|
||||
if parts.length >= 3
|
||||
frontmatter = YAML.safe_load(parts[1]) || {}
|
||||
return [frontmatter, parts[2].strip]
|
||||
end
|
||||
end
|
||||
[{}, content]
|
||||
end
|
||||
|
||||
def render_markdown(content)
|
||||
renderer = Redcarpet::Render::HTML.new(
|
||||
hard_wrap: true,
|
||||
link_attributes: { target: "_blank", rel: "noopener" }
|
||||
)
|
||||
markdown = Redcarpet::Markdown.new(
|
||||
renderer,
|
||||
autolink: true,
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
strikethrough: true,
|
||||
highlight: true,
|
||||
footnotes: true
|
||||
)
|
||||
markdown.render(content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Upload < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
# UUID v7 primary key (automatic via migration)
|
||||
|
||||
belongs_to :user
|
||||
|
|
@ -9,6 +11,15 @@ class Upload < ApplicationRecord
|
|||
# Delegate file metadata to blob (no duplication!)
|
||||
delegate :filename, :byte_size, :content_type, :checksum, to: :blob
|
||||
|
||||
# Search configuration
|
||||
pg_search_scope :search_by_filename,
|
||||
associated_against: {
|
||||
blob: :filename
|
||||
},
|
||||
using: {
|
||||
tsearch: { prefix: true }
|
||||
}
|
||||
|
||||
# Aliases for consistency
|
||||
alias_method :file_size, :byte_size
|
||||
alias_method :mime_type, :content_type
|
||||
|
|
|
|||
|
|
@ -52,4 +52,8 @@ class User < ApplicationRecord
|
|||
def total_storage_gb
|
||||
(total_storage_bytes / 1.gigabyte.to_f).round(2)
|
||||
end
|
||||
|
||||
def total_storage_formatted
|
||||
ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
18
app/policies/upload_policy.rb
Normal file
18
app/policies/upload_policy.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class UploadPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
# Users can delete their own uploads, admins can delete any upload
|
||||
user.is_admin? || record.user_id == user.id
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if user.is_admin?
|
||||
scope.all
|
||||
else
|
||||
scope.where(user: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -21,8 +21,8 @@ class CDNStatsService
|
|||
def self.user_stats(user)
|
||||
{
|
||||
total_files: user.total_files,
|
||||
total_storage: user.total_storage_gb,
|
||||
storage_formatted: "#{user.total_storage_gb} GB",
|
||||
total_storage: user.total_storage_bytes,
|
||||
storage_formatted: user.total_storage_formatted,
|
||||
files_today: user.uploads.today.count,
|
||||
files_this_week: user.uploads.this_week.count,
|
||||
recent_uploads: user.uploads.includes(:blob).recent.limit(5)
|
||||
|
|
@ -34,13 +34,12 @@ class CDNStatsService
|
|||
def self.calculate_global_stats
|
||||
total_files = Upload.count
|
||||
total_storage_bytes = Upload.joins(:blob).sum('active_storage_blobs.byte_size')
|
||||
total_storage_gb = (total_storage_bytes / 1.gigabyte.to_f).round(2)
|
||||
total_users = User.joins(:uploads).distinct.count
|
||||
|
||||
{
|
||||
total_files: total_files,
|
||||
total_storage_gb: total_storage_gb,
|
||||
storage_formatted: "#{total_storage_gb} GB",
|
||||
total_storage_bytes: total_storage_bytes,
|
||||
storage_formatted: ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes),
|
||||
total_users: total_users,
|
||||
files_today: Upload.today.count,
|
||||
files_this_week: Upload.this_week.count
|
||||
|
|
|
|||
49
app/views/docs/pages/getting-started.md
Normal file
49
app/views/docs/pages/getting-started.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
title: Getting Started
|
||||
icon: rocket
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Getting Started~! ✨
|
||||
|
||||
Welcome to Hack Club CDN, nya~! This guide will help you get your paws on file hosting in no time!
|
||||
|
||||
## Sign In
|
||||
|
||||
Click the **Sign in with Hack Club** button on the homepage to authenticate with your Hack Club account! It's super easy, I promise~ 🐱
|
||||
|
||||
## Uploading Files
|
||||
|
||||
Once you're logged in, here's how to share your creations with the world:
|
||||
|
||||
1. Pounce over to **My Files**
|
||||
2. Click **Upload** or drag and drop files (like a cat batting at a toy~)
|
||||
3. Your file gets a purrmanent URL! How neat is that?
|
||||
|
||||
## Sharing Files
|
||||
|
||||
Every uploaded file gets its own special URL you can share anywhere, nya~!
|
||||
|
||||
```
|
||||
https://cdn.hackclub.com/your-file-id
|
||||
```
|
||||
|
||||
Copy it, share it, do whatever you want with it! It's *your* file now~ ✨
|
||||
|
||||
## File Limits
|
||||
|
||||
- Maximum file size: varies by account (no eating too much at once!)
|
||||
- Supported formats: images, documents, archives, and more~
|
||||
|
||||
## API Usage
|
||||
|
||||
For those of you who want to get technical (*adjusts glasses*), check out the [API documentation](https://github.com/hackclub/cdn#-api-usage) for programmatic uploads!
|
||||
|
||||
## Need Help?
|
||||
|
||||
Don't be shy, nya~! We're here for you:
|
||||
|
||||
- Join the [#cdn channel on Slack](https://hackclub.enterprise.slack.com/archives/C016DEDUL87) and say hi!
|
||||
- Open an issue on [GitHub](https://github.com/hackclub/cdn/issues) if something's broken
|
||||
|
||||
Now go upload something cool! I believe in you~ 💖
|
||||
4
app/views/docs/show.html.erb
Normal file
4
app/views/docs/show.html.erb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<% content_for(:title) { "#{@doc.title} - Docs" } %>
|
||||
|
||||
<%= render(Components::HeaderBar.new) unless signed_in? %>
|
||||
<%= render Components::Docs::Page.new(doc: @doc, docs: @docs) %>
|
||||
1
app/views/uploads/index.html.erb
Normal file
1
app/views/uploads/index.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= render Components::Uploads::Index.new(uploads: @uploads, query: params[:query]) %>
|
||||
1
app/views/uploads/new.html.erb
Normal file
1
app/views/uploads/new.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= render Components::Uploads::New.new %>
|
||||
5
config/initializers/high_voltage.rb
Normal file
5
config/initializers/high_voltage.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
HighVoltage.configure do |config|
|
||||
config.routes = false
|
||||
end
|
||||
|
|
@ -5,6 +5,11 @@ Rails.application.routes.draw do
|
|||
post "/auth/hack_club", as: :hack_club_auth
|
||||
get "/auth/hack_club/callback", to: "sessions#create"
|
||||
get "/auth/failure", to: "sessions#failure"
|
||||
|
||||
resources :uploads, only: [:index, :new, :create, :destroy]
|
||||
|
||||
get "/docs", to: redirect("/docs/getting-started")
|
||||
get "/docs/:id", to: "docs#show", as: :doc
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ export default defineConfig({
|
|||
},
|
||||
|
||||
optimizeDeps: {
|
||||
include: ['@primer/view-components']
|
||||
include: ['@primer/view-components'],
|
||||
esbuildOptions: {
|
||||
keepNames: true
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue