add a webapplication to view captured pastes

This commit is contained in:
booboy 2025-07-26 14:27:35 -05:00
parent 92538b42b3
commit 3b225c3e50
14 changed files with 1247 additions and 106 deletions

View file

@ -14,7 +14,7 @@ gem 'puma', '~> 5.6'
# Use SCSS for stylesheets
gem 'sass-rails', '~> 6.0'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 4.0'
# gem 'webpacker', '~> 4.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
@ -30,6 +30,7 @@ gem 'sidekiq', '~> 6.5'
gem 'sidekiq-scheduler'
gem 'pry'
gem "sidekiq-cron", "~> 1.1"
gem 'kaminari', '~> 1.2'
# Use Active Storage variant
# gem 'image_processing', '~> 1.2'

View file

@ -4,12 +4,57 @@ class PastesController < ApplicationController
# GET /pastes
# GET /pastes.json
def index
@pastes = Paste.all
# Get combined pastes from both SQLite and Elasticsearch
if params[:search].present?
# For search, use Elasticsearch only for now
@pastes = Paste.from_elasticsearch(limit: 20).select do |paste|
paste.content.downcase.include?(params[:search].downcase) ||
paste.title.downcase.include?(params[:search].downcase)
end
else
# Show combined recent pastes
@pastes = Paste.recent_combined(limit: 20)
end
# Filter by language if specified
if params[:language].present?
@pastes = @pastes.select { |paste| paste.language == params[:language] }
end
# For filter dropdown - combine languages from both sources
sqlite_languages = Paste.languages
es_languages = @pastes.map(&:language).uniq.compact
@languages = (sqlite_languages + es_languages).uniq.sort
# Stats for dashboard
@total_pastes = Paste.count + get_elasticsearch_count
@total_languages = @languages.count
end
private
def get_elasticsearch_count
begin
client = Elasticsearch::Client.new(url: ENV.fetch('ELASTICSEARCH_URL', 'http://pastebinner-elasticsearch:9200'))
response = client.count(index: 'pastes')
response['count'] || 0
rescue => e
Rails.logger.error "Elasticsearch count error: #{e.message}"
0
end
end
# GET /pastes/1
# GET /pastes/1.json
def show
# Increment hit counter only for SQLite pastes
@paste.increment_hits! if @paste.respond_to?(:increment_hits!)
# For raw text display
if params[:raw] == 'true'
render plain: @paste.content, content_type: 'text/plain'
return
end
end
# GET /pastes/new
@ -63,12 +108,32 @@ class PastesController < ApplicationController
private
# Use callbacks to share common setup or constraints between actions.
# def set_paste
# @paste = Paste.find(params[:id])
# end
def set_paste
# Try to find in SQLite first
begin
@paste = Paste.find(params[:id])
rescue ActiveRecord::RecordNotFound
# If not found in SQLite, try Elasticsearch by document ID
@paste = find_elasticsearch_paste(params[:id])
unless @paste
redirect_to pastes_path, alert: 'Paste not found'
end
end
end
def find_elasticsearch_paste(doc_id)
begin
client = Elasticsearch::Client.new(url: ENV.fetch('ELASTICSEARCH_URL', 'http://pastebinner-elasticsearch:9200'))
response = client.get(index: 'pastes', id: doc_id)
Paste.elasticsearch_paste_to_object(response)
rescue => e
Rails.logger.error "Elasticsearch get error: #{e.message}"
nil
end
end
# Never trust parameters from the scary internet, only allow the white list through.
# def paste_params
# params.require(:paste).permit(:search_pastes)
# end
def paste_params
params.require(:paste).permit(:title, :content, :language, :expires_at)
end
end

View file

@ -1,2 +1,62 @@
module ApplicationHelper
def syntax_highlight(content, language = 'text', options = {})
return '' if content.blank?
css_classes = ["language-#{language}"]
css_classes << 'line-numbers' if options[:line_numbers]
content_tag :pre, class: css_classes.join(' ') do
content_tag :code, content, class: "language-#{language}"
end
end
def language_icon(language)
icons = {
'javascript' => '🟨',
'python' => '🐍',
'ruby' => '💎',
'java' => '☕',
'php' => '🐘',
'html' => '🌐',
'css' => '🎨',
'sql' => '🗄️',
'json' => '📋',
'xml' => '📄',
'bash' => '🐚',
'go' => '🐹',
'rust' => '🦀',
'typescript' => '🔷'
}
icons[language.to_s.downcase] || '📄'
end
def time_ago_in_words_short(time)
return '' unless time
distance = Time.current - time
case distance
when 0..59
"#{distance.to_i}s ago"
when 60..3599
"#{(distance / 60).to_i}m ago"
when 3600..86399
"#{(distance / 3600).to_i}h ago"
when 86400..2591999
"#{(distance / 86400).to_i}d ago"
else
time.strftime('%b %d, %Y')
end
end
def truncate_content(content, length = 150)
return '' if content.blank?
if content.length > length
content[0..length].gsub(/\s\w+\s*$/, '...')
else
content
end
end
end

View file

@ -8,6 +8,35 @@ require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")
// Initialize Prism.js on page load and turbolinks navigation
document.addEventListener('turbolinks:load', function() {
if (typeof Prism !== 'undefined') {
Prism.highlightAll();
}
});
// Handle copy to clipboard functionality
document.addEventListener('turbolinks:load', function() {
// Add click handler for copy buttons
document.querySelectorAll('.copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const target = this.getAttribute('data-target');
const textArea = document.querySelector(target);
if (textArea) {
textArea.select();
document.execCommand('copy');
// Provide visual feedback
const originalText = this.textContent;
this.textContent = 'Copied!';
setTimeout(() => {
this.textContent = originalText;
}, 1000);
}
});
});
});
// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)

View file

@ -1,31 +1,232 @@
require 'elasticsearch/model'
require 'ostruct'
class Paste < ApplicationRecord
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
include Elasticsearch::Model
# include Elasticsearch::Model::Callbacks # Temporarily disabled for seeding
# Pagination
paginates_per 20
# this doesnt work yet, but this is how we build a way to progrmatically query ES
# def self.search(query)
# __elasticsearch__.search(
# {
# query: {
# multi_match: {
# query: query,
# }
# },
# "highlight": {
# "pre_tags": [
# "@kibana-highlighted-field@"
# ],
# "post_tags": [
# "@/kibana-highlighted-field@"
# ],
# "fields": {
# "*": {}
# }
# }
# }
# )
# end
# Validations
validates :content, presence: true
validates :paste_key, uniqueness: true, allow_blank: true
# Callbacks
before_save :set_defaults
before_save :detect_language
before_save :calculate_size
# Scopes
scope :by_language, ->(lang) { where(language: lang) if lang.present? }
scope :recent, -> { order(created_at: :desc) }
scope :popular, -> { order(hits: :desc) }
# Class methods
def self.search_content(query)
return all if query.blank?
__elasticsearch__.search({
query: {
multi_match: {
query: query,
fields: ['content', 'title']
}
},
highlight: {
pre_tags: ['<mark>'],
post_tags: ['</mark>'],
fields: {
content: {}
}
}
})
end
def self.languages
distinct.pluck(:language).compact.sort
end
# Fetch pastes from Elasticsearch
def self.from_elasticsearch(limit: 20, offset: 0)
begin
client = Elasticsearch::Client.new(url: ENV.fetch('ELASTICSEARCH_URL', 'http://pastebinner-elasticsearch:9200'))
response = client.search(
index: 'pastes',
body: {
size: limit,
from: offset,
sort: [{ '_score': { order: 'desc' } }],
query: { match_all: {} }
}
)
response['hits']['hits'].map do |hit|
elasticsearch_paste_to_object(hit)
end
rescue => e
Rails.logger.error "Elasticsearch error: #{e.message}"
[]
end
end
# Convert Elasticsearch hit to paste-like object
def self.elasticsearch_paste_to_object(hit)
source = hit['_source']
paste_id = extract_paste_key_from_metadata(source['paste_metadata'])
OpenStruct.new(
id: hit['_id'],
title: "Scraped Paste #{paste_id}",
content: source['paste_text'] || '',
language: detect_language_from_content(source['paste_text']),
created_at: Time.current,
updated_at: Time.current,
hits: 0,
size: (source['paste_text'] || '').length,
paste_key: paste_id,
source: 'elasticsearch'
)
end
# Extract paste key from metadata array
def self.extract_paste_key_from_metadata(metadata)
return 'unknown' unless metadata.is_a?(Array) && metadata.length >= 2
url = metadata[1] # The scrape URL
if url =~ /\/api_scrape_item\.php\?i=([A-Za-z0-9]+)/
$1
else
'unknown'
end
end
# Simple language detection based on content
def self.detect_language_from_content(content)
return 'text' if content.blank?
case content
when /^\s*<\?php/m then 'php'
when /^\s*#!/m then 'bash'
when /function\s*\(/m, /=>\s*{/m, /console\.log/m then 'javascript'
when /def\s+\w+/m, /import\s+\w+/m then 'python'
when /<html/m, /<DOCTYPE/m then 'html'
when /SELECT\s+/im, /INSERT\s+/im, /UPDATE\s+/im then 'sql'
when /{[^}]*}/m, /\[[^\]]*\]/m then 'json'
else 'text'
end
end
# Get combined pastes from both sources
def self.recent_combined(limit: 20)
# Get from SQLite
sqlite_pastes = recent.limit(limit/2).to_a
# Get from Elasticsearch
es_pastes = from_elasticsearch(limit: limit/2)
# Combine and return
(sqlite_pastes + es_pastes).sort_by { |p| p.created_at || Time.current }.reverse.first(limit)
end
# Instance methods
def display_title
title.present? ? title : "Paste ##{id}"
end
def short_content(limit = 200)
return '' if content.blank?
content.length > limit ? "#{content[0..limit]}..." : content
end
def formatted_size
return '0 B' if size.nil? || size.zero?
units = %w[B KB MB GB]
base = 1024
exp = (Math.log(size) / Math.log(base)).to_i
exp = [exp, units.size - 1].min
"%.1f %s" % [size.to_f / base**exp, units[exp]]
end
def language_display
language.present? ? language.humanize : 'Plain Text'
end
def increment_hits!
increment!(:hits)
end
def expired?
expires_at && expires_at < Time.current
end
def prism_language
# Map common languages to Prism.js language identifiers
language_mappings = {
'javascript' => 'javascript',
'js' => 'javascript',
'python' => 'python',
'py' => 'python',
'ruby' => 'ruby',
'rb' => 'ruby',
'java' => 'java',
'cpp' => 'cpp',
'c++' => 'cpp',
'c' => 'c',
'html' => 'html',
'css' => 'css',
'php' => 'php',
'sql' => 'sql',
'json' => 'json',
'xml' => 'xml',
'yaml' => 'yaml',
'yml' => 'yaml',
'bash' => 'bash',
'shell' => 'bash',
'go' => 'go',
'rust' => 'rust',
'typescript' => 'typescript',
'ts' => 'typescript'
}
return language_mappings[language.to_s.downcase] if language.present?
'text'
end
private
def set_defaults
self.paste_key = SecureRandom.hex(8) if paste_key.blank?
self.hits ||= 0
self.title = "Untitled" if title.blank?
end
def detect_language
return if content.blank?
# Simple language detection based on content patterns
case content
when /def\s+\w+\s*\(/
self.language = 'python' if language.blank?
when /function\s+\w+\s*\(/
self.language = 'javascript' if language.blank?
when /class\s+\w+/
self.language = 'java' if language.blank?
when /<\?php/
self.language = 'php' if language.blank?
when /<!DOCTYPE|<html/
self.language = 'html' if language.blank?
when /SELECT|INSERT|UPDATE|DELETE/i
self.language = 'sql' if language.blank?
else
self.language = 'text' if language.blank?
end
end
def calculate_size
self.size = content.present? ? content.bytesize : 0
end
end

View file

@ -6,11 +6,55 @@
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<!-- Prism.js CSS Theme -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
<!-- Rails UJS and Turbolinks from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"></script>
<!-- Prism.js Core and Language Packs -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
<!-- Line Numbers CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.css" />
<script>
// Initialize Prism.js on page load and turbolinks navigation
document.addEventListener('turbolinks:load', function() {
if (typeof Prism !== 'undefined') {
Prism.highlightAll();
}
// Handle copy to clipboard functionality
document.querySelectorAll('.copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const target = this.getAttribute('data-target');
const textArea = document.querySelector(target);
if (textArea) {
textArea.select();
document.execCommand('copy');
// Provide visual feedback
const originalText = this.textContent;
this.textContent = 'Copied!';
setTimeout(() => {
this.textContent = originalText;
}, 1000);
}
});
});
});
</script>
</head>
<body>
<%= render 'search/form' %>
<% unless controller_name == 'pastes' %>
<%= render 'search/form' %>
<% end %>
<%= yield %>
</body>
</html>

View file

@ -1,27 +1,174 @@
<p id="notice"><%= notice %></p>
<h1>Pastebinner - Browse Pastes</h1>
<h1>Pastes</h1>
<table>
<thead>
<tr>
<th>Search pastes</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<div class="simple-container">
<h2>Recent Pastes (<%= @total_pastes %> total - SQLite + Elasticsearch)</h2>
<!-- Search and Filter Form -->
<%= form_with url: pastes_path, method: :get, local: true, class: "search-form" do |f| %>
<div class="search-controls">
<%= f.text_field :search, placeholder: "Search pastes...", value: params[:search], class: "search-input" %>
<%= f.select :language, options_for_select([["All Languages", ""]] + @languages.map { |l| [l.titleize, l] }, params[:language]), {}, { class: "language-select" } %>
<%= f.submit "Search", class: "search-btn" %>
<%= link_to "Clear", pastes_path, class: "clear-btn" %>
</div>
<% end %>
<% if @pastes.any? %>
<% @pastes.each do |paste| %>
<tr>
<td><%= paste.search_pastes %></td>
<td><%= link_to 'Show', paste %></td>
<td><%= link_to 'Edit', edit_paste_path(paste) %></td>
<td><%= link_to 'Destroy', paste, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<div class="paste-card <%= 'elasticsearch-paste' if paste.respond_to?(:source) && paste.source == 'elasticsearch' %>">
<div class="paste-header">
<h3><%= link_to paste.display_title, paste_path(paste.id) %></h3>
<% if paste.respond_to?(:source) && paste.source == 'elasticsearch' %>
<span class="source-badge">Live Scraped</span>
<% else %>
<span class="source-badge local">Local</span>
<% end %>
</div>
<p class="paste-meta">
Language: <strong><%= paste.language %></strong> |
Size: <strong><%= paste.size || (paste.content&.length || 0) %> characters</strong>
<% if paste.paste_key.present? %>
| Key: <strong><%= paste.paste_key %></strong>
<% end %>
</p>
<div class="preview">
<pre><code class="language-<%= paste.language %>"><%= truncate(paste.content, length: 300) %></code></pre>
</div>
</div>
<% end %>
</tbody>
</table>
<% else %>
<p>No pastes found. <%= link_to "Clear filters", pastes_path %></p>
<% end %>
</div>
<br>
<style>
.simple-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
<%= link_to 'New Paste', new_paste_path %>
.search-form {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.search-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.language-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.search-btn, .clear-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
text-decoration: none;
font-weight: bold;
cursor: pointer;
}
.search-btn {
background: #007bff;
color: white;
}
.clear-btn {
background: #6c757d;
color: white;
}
.paste-card {
border: 1px solid #ddd;
margin: 15px 0;
padding: 20px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.paste-card.elasticsearch-paste {
border-left: 4px solid #28a745;
}
.paste-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.paste-card h3 {
margin: 0;
flex: 1;
}
.source-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.source-badge {
background: #28a745;
color: white;
}
.source-badge.local {
background: #6c757d;
color: white;
}
.paste-meta {
color: #666;
margin: 10px 0;
font-size: 14px;
}
.preview {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
border: 1px solid #e9ecef;
overflow-x: auto;
}
.preview code {
background: none;
padding: 0;
font-size: 13px;
line-height: 1.4;
}
@media (max-width: 768px) {
.search-controls {
flex-direction: column;
align-items: stretch;
}
.paste-header {
flex-direction: column;
gap: 10px;
}
}
</style>

View file

@ -1,9 +1,356 @@
<p id="notice"><%= notice %></p>
<div class="paste-viewer">
<!-- Header Section -->
<div class="paste-header">
<div class="header-content">
<div class="paste-info">
<h1 class="paste-title">
<%= language_icon(@paste.language) %>
<%= @paste.display_title %>
</h1>
<div class="paste-metadata">
<div class="meta-group">
<span class="meta-label">Language:</span>
<span class="meta-value"><%= @paste.language_display %></span>
</div>
<div class="meta-group">
<span class="meta-label">Size:</span>
<span class="meta-value"><%= @paste.formatted_size %></span>
</div>
<div class="meta-group">
<span class="meta-label">Created:</span>
<span class="meta-value"><%= @paste.created_at.strftime('%B %d, %Y at %I:%M %p') %></span>
</div>
<div class="meta-group">
<span class="meta-label">Views:</span>
<span class="meta-value"><%= @paste.hits %> hits</span>
</div>
<% if @paste.paste_key.present? %>
<div class="meta-group">
<span class="meta-label">ID:</span>
<span class="meta-value"><%= @paste.paste_key %></span>
</div>
<% end %>
</div>
</div>
<div class="paste-actions">
<%= link_to "📄 Raw Text", paste_path(@paste, raw: true),
class: "action-btn secondary", target: "_blank" %>
<button id="copy-paste-btn" class="action-btn primary">
📋 Copy to Clipboard
</button>
<%= link_to "🔙 Back to List", pastes_path, class: "action-btn secondary" %>
</div>
</div>
</div>
<p>
<strong>Search pastes:</strong>
<%= @paste.search_pastes %>
</p>
<!-- Code Content -->
<div class="code-container">
<% if @paste.content.present? %>
<div class="code-header">
<span class="language-badge">
<%= language_icon(@paste.language) %> <%= @paste.language_display %>
</span>
<span class="line-count">
📏 <%= @paste.content.lines.count %> lines
</span>
</div>
<div class="code-content line-numbers" id="paste-content">
<%= syntax_highlight(@paste.content, @paste.prism_language, line_numbers: true) %>
</div>
<% else %>
<div class="no-content">
<div class="no-content-icon">📭</div>
<h3>No Content Available</h3>
<p>This paste appears to be empty or the content could not be loaded.</p>
</div>
<% end %>
</div>
<%= link_to 'Edit', edit_paste_path(@paste) %> |
<%= link_to 'Back', pastes_path %>
<!-- Hidden textarea for copying -->
<textarea id="copy-source" style="position: absolute; left: -9999px;"><%= @paste.content %></textarea>
</div>
<style>
body {
margin: 0;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
.paste-viewer {
min-height: 100vh;
}
.paste-header {
background: white;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.paste-info {
flex: 1;
}
.paste-title {
font-size: 1.875rem;
margin: 0 0 15px 0;
color: #1a202c;
font-weight: 600;
}
.paste-metadata {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 10px;
}
.meta-group {
display: flex;
align-items: center;
gap: 5px;
}
.meta-label {
font-weight: 500;
color: #4a5568;
font-size: 0.875rem;
}
.meta-value {
color: #2d3748;
font-size: 0.875rem;
background: #f7fafc;
padding: 2px 8px;
border-radius: 4px;
}
.paste-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.action-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
}
.action-btn.primary {
background: #4299e1;
color: white;
}
.action-btn.primary:hover {
background: #3182ce;
transform: translateY(-1px);
}
.action-btn.secondary {
background: #e2e8f0;
color: #4a5568;
}
.action-btn.secondary:hover {
background: #cbd5e0;
transform: translateY(-1px);
}
.code-container {
max-width: 1200px;
margin: 20px auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}
.code-header {
background: #2d3748;
color: white;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.language-badge {
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
.line-count {
opacity: 0.8;
}
.code-content {
background: #1a202c !important;
color: #e2e8f0;
overflow-x: auto;
max-height: 80vh;
overflow-y: auto;
}
.code-content pre {
margin: 0;
padding: 20px;
background: transparent !important;
color: inherit !important;
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
font-size: 0.875rem;
line-height: 1.6;
}
.code-content code {
background: transparent !important;
color: inherit !important;
}
/* Line numbers styling */
.line-numbers .line-numbers-rows {
border-right: 1px solid #4a5568;
margin-right: 15px;
padding-right: 15px;
}
.line-numbers .line-numbers-rows > span:before {
color: #718096;
font-weight: normal;
}
.no-content {
text-align: center;
padding: 60px 20px;
color: #718096;
}
.no-content-icon {
font-size: 4rem;
margin-bottom: 20px;
}
.no-content h3 {
margin: 0 0 10px 0;
color: #4a5568;
}
.no-content p {
margin: 0;
opacity: 0.8;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
}
.paste-actions {
justify-content: stretch;
}
.action-btn {
flex: 1;
justify-content: center;
}
.paste-metadata {
flex-direction: column;
gap: 10px;
}
.meta-group {
justify-content: space-between;
}
.code-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.code-content {
font-size: 0.75rem;
}
}
/* Custom scrollbar for code content */
.code-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.code-content::-webkit-scrollbar-track {
background: #2d3748;
}
.code-content::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 4px;
}
.code-content::-webkit-scrollbar-thumb:hover {
background: #718096;
}
</style>
<script>
document.addEventListener('turbolinks:load', function() {
const copyBtn = document.getElementById('copy-paste-btn');
const copySource = document.getElementById('copy-source');
if (copyBtn && copySource) {
copyBtn.addEventListener('click', function() {
copySource.select();
copySource.setSelectionRange(0, 99999); // For mobile devices
try {
document.execCommand('copy');
// Provide visual feedback
const originalText = this.innerHTML;
this.innerHTML = '✅ Copied!';
this.style.background = '#48bb78';
setTimeout(() => {
this.innerHTML = originalText;
this.style.background = '#4299e1';
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy to clipboard');
}
});
}
});
</script>

View file

@ -1,9 +1,3 @@
# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem 'sqlite3'
#
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
@ -13,13 +7,10 @@ development:
<<: *default
database: db/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: db/test.sqlite3
production:
<<: *default
database: db/production.sqlite3
database: db/production.sqlite3

View file

@ -1,5 +1,3 @@
# Note: You must restart bin/webpack-dev-server for changes to take effect
default: &default
source_path: app/javascript
source_entry_path: packs
@ -8,15 +6,8 @@ default: &default
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
resolved_paths: []
# Reload manifest.json on all requests so we reload latest compiled packs
cache_manifest: false
# Extract and emit a css file
extract_css: false
static_assets_extensions:
@ -51,18 +42,14 @@ default: &default
development:
<<: *default
compile: true
check_yarn_integrity: false
# Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules
check_yarn_integrity: true
# Reference: https://webpack.js.org/configuration/dev-server/
dev_server:
https: false
host: localhost
port: 3035
public: localhost:3035
hmr: false
# Inline should be set to true if using HMR
inline: true
overlay: true
compress: true
@ -74,22 +61,13 @@ development:
watch_options:
ignored: '**/node_modules/**'
test:
<<: *default
compile: true
# Compile test packs to a separate directory
public_output_path: packs-test
production:
<<: *default
# Production depends on precompilation of packs prior to booting for performance.
compile: false
# Extract and emit a css file
extract_css: true
# Cache manifest.json for performance
cache_manifest: true
cache_manifest: true

View file

@ -0,0 +1,16 @@
class AddFieldsToPastes < ActiveRecord::Migration[6.1]
def change
add_column :pastes, :title, :string
add_column :pastes, :language, :string
add_column :pastes, :paste_key, :string
add_column :pastes, :content, :text
add_column :pastes, :size, :integer
add_column :pastes, :hits, :integer, default: 0
add_column :pastes, :created_by, :string
add_column :pastes, :expires_at, :datetime
add_index :pastes, :paste_key, unique: true
add_index :pastes, :language
add_index :pastes, :created_at
end
end

View file

@ -1,7 +1,267 @@
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
# Character.create(name: 'Luke', movie: movies.first)
# Sample paste data for testing the web interface
puts "Creating sample pastes..."
# Python sample
Paste.create!(
title: "Hello World Python",
content: <<~PYTHON,
def hello_world():
"""A simple greeting function"""
name = input("What's your name? ")
print(f"Hello, {name}!")
return name
if __name__ == "__main__":
hello_world()
PYTHON
language: "python"
)
# JavaScript sample
Paste.create!(
title: "React Component",
content: <<~JAVASCRIPT,
import React, { useState, useEffect } from 'react';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(error => {
console.error('Error fetching user:', error);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div className="user-profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>Joined: {new Date(user.createdAt).toLocaleDateString()}</p>
</div>
);
};
export default UserProfile;
JAVASCRIPT
language: "javascript"
)
# Ruby sample
Paste.create!(
title: "Rails Migration",
content: <<~RUBY,
class CreateUsersTable < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false, index: { unique: true }
t.string :password_digest
t.text :bio
t.boolean :active, default: true
t.timestamps
end
add_index :users, :name
add_index :users, :created_at
end
end
RUBY
language: "ruby"
)
# SQL sample
Paste.create!(
title: "Database Query",
content: <<~SQL,
SELECT
u.name,
u.email,
COUNT(p.id) as post_count,
AVG(p.views) as avg_views
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE u.active = true
AND u.created_at >= '2024-01-01'
GROUP BY u.id, u.name, u.email
HAVING COUNT(p.id) > 5
ORDER BY avg_views DESC
LIMIT 20;
SQL
language: "sql"
)
# HTML sample
Paste.create!(
title: "Landing Page Template",
content: <<~HTML,
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Awesome App</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="hero">
<nav class="navbar">
<div class="logo">MyApp</div>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
<div class="hero-content">
<h1>Build Amazing Things</h1>
<p>The fastest way to create and deploy your applications</p>
<button class="cta-button">Get Started</button>
</div>
</header>
<section id="features" class="features">
<h2>Why Choose Us?</h2>
<div class="feature-grid">
<div class="feature">
<h3>Fast</h3>
<p>Lightning-fast performance</p>
</div>
<div class="feature">
<h3>Secure</h3>
<p>Enterprise-grade security</p>
</div>
<div class="feature">
<h3>Scalable</h3>
<p>Grows with your business</p>
</div>
</div>
</section>
</body>
</html>
HTML
language: "html"
)
# CSS sample
Paste.create!(
title: "Modern CSS Grid Layout",
content: <<~CSS,
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.card-header {
padding: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.card-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.card-body {
padding: 1.5rem;
}
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
padding: 1rem;
}
}
CSS
language: "css"
)
# JSON sample
Paste.create!(
title: "API Response Example",
content: <<~JSON,
{
"status": "success",
"data": {
"users": [
{
"id": 1,
"username": "john_doe",
"email": "john@example.com",
"profile": {
"firstName": "John",
"lastName": "Doe",
"avatar": "https://example.com/avatars/john.jpg",
"bio": "Software developer passionate about clean code"
},
"preferences": {
"theme": "dark",
"notifications": {
"email": true,
"push": false,
"sms": false
},
"privacy": {
"profilePublic": true,
"showEmail": false
}
},
"stats": {
"postsCount": 42,
"followersCount": 156,
"followingCount": 89
},
"createdAt": "2024-01-15T10:30:00Z",
"lastLoginAt": "2024-01-26T14:45:23Z"
}
],
"pagination": {
"currentPage": 1,
"totalPages": 10,
"totalItems": 100,
"itemsPerPage": 10
}
},
"meta": {
"timestamp": "2024-01-26T15:00:00Z",
"version": "1.0",
"requestId": "req_123456789"
}
}
JSON
language: "json"
)
puts "Created #{Paste.count} sample pastes!"
puts "You can now browse them at http://localhost:3000"

View file

@ -26,6 +26,8 @@ services:
- redis
env_file:
- .env
environment:
- DISABLE_SPRING=1
networks:
- elastic
elasticsearch:

View file

@ -1,7 +1,7 @@
class RedisHelper
attr_accessor :connection
def initialize(host: host, db: db)
def initialize(host: nil, db: nil)
@host = host
@db = db
@connection = Redis.new(host: host, db: db)