This commit is contained in:
24c02 2026-01-29 10:21:54 -05:00
parent 5ce2ea55ad
commit be95fac10a
29 changed files with 898 additions and 24 deletions

View file

@ -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"

View file

@ -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

View 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

View 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

View 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

View file

@ -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?

View file

@ -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 },

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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();
}
})();

View file

@ -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";

View file

@ -7,6 +7,7 @@
@use "@/styles/dark_mode";
@use "@/styles/hca";
@use "@/styles/admin_tool";
@use "@/styles/file_dropzone";
.app-header {
position: static;

View 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
View 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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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~ 💖

View 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) %>

View file

@ -0,0 +1 @@
<%= render Components::Uploads::Index.new(uploads: @uploads, query: params[:query]) %>

View file

@ -0,0 +1 @@
<%= render Components::Uploads::New.new %>

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
HighVoltage.configure do |config|
config.routes = false
end

View file

@ -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.

View file

@ -15,7 +15,10 @@ export default defineConfig({
},
optimizeDeps: {
include: ['@primer/view-components']
include: ['@primer/view-components'],
esbuildOptions: {
keepNames: true
}
},
server: {