rss feed and download stats
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
class Api::RssController < ApiController
|
||||
def blog
|
||||
xml = RssService.new.blog_feed
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
def releases
|
||||
xml = RssService.new.releases_feed
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class DownloadsController < ApplicationController
|
||||
skip_forgery_protection
|
||||
|
||||
def show
|
||||
path = params[:path]
|
||||
return head :bad_request if path.blank?
|
||||
|
||||
full_path = DownloadService.new.call(
|
||||
path: path,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
referer: request.referer
|
||||
)
|
||||
|
||||
if full_path
|
||||
ext = File.extname(full_path.to_s).delete_prefix(".")
|
||||
mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
send_file full_path, disposition: "attachment", type: mime
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class Download < ApplicationRecord
|
||||
belongs_to :release, optional: true
|
||||
|
||||
validates :file_path, presence: true
|
||||
end
|
||||
@@ -2,6 +2,7 @@ class Release < ApplicationRecord
|
||||
self.table_name = "releases"
|
||||
|
||||
belongs_to :software
|
||||
has_many :downloads
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ class Software < ApplicationRecord
|
||||
has_many :software_images, foreign_key: :software_id, dependent: :destroy
|
||||
has_many :images, through: :software_images
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :downloads, through: :releases
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
|
||||
accepts_nested_attributes_for :software_images, allow_destroy: true
|
||||
|
||||
@@ -14,4 +14,5 @@ class ReleaseSerializer < Blueprinter::Base
|
||||
field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
|
||||
field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
|
||||
field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
|
||||
field(:downloadCount) { |r| r.downloads.size }
|
||||
end
|
||||
|
||||
@@ -3,4 +3,5 @@ class SoftwareDetailSerializer < Blueprinter::Base
|
||||
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) }
|
||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil }
|
||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil }
|
||||
field(:totalDownloads) { |sw, _| Download.where(release: sw.releases).count }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class DownloadService
|
||||
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
|
||||
|
||||
def call(path:, ip:, user_agent:, referer:)
|
||||
full_path = BASE_PATH.join(path.to_s)
|
||||
return nil unless File.file?(full_path)
|
||||
|
||||
release = Release.find_by("cartridge_path LIKE ? OR source_path LIKE ?",
|
||||
"%#{path}%", "%#{path}%")
|
||||
|
||||
Download.create!(
|
||||
file_path: path,
|
||||
release: release,
|
||||
ip_address: ip,
|
||||
user_agent: user_agent&.truncate(500),
|
||||
referer: referer&.truncate(500)
|
||||
)
|
||||
|
||||
full_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
require "rss"
|
||||
|
||||
class RssService
|
||||
SITE_URL = ENV.fetch("SITE_URL", "https://teletypegames.hu").freeze
|
||||
|
||||
def blog_feed
|
||||
pages = WikiService.new.pages(tag: "blog", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Blog"
|
||||
maker.channel.link = "#{SITE_URL}/blog"
|
||||
maker.channel.description = "Latest blog posts from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{SITE_URL}/blog/#{page["route"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def releases_feed
|
||||
releases = Release.includes(:software)
|
||||
.order(created_at: :desc)
|
||||
.limit(50)
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Releases"
|
||||
maker.channel.link = "#{SITE_URL}/catalog"
|
||||
maker.channel.description = "Latest game releases from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
releases.each do |release|
|
||||
sw = release.software
|
||||
next unless sw
|
||||
|
||||
maker.items.new_item do |item|
|
||||
item.title = "#{sw.title} v#{release.version}"
|
||||
item.link = "#{SITE_URL}/catalog/#{sw.name}"
|
||||
item.description = "#{sw.title} #{release.version} released – #{sw.desc}"
|
||||
item.pubDate = release.created_at
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class SoftwareHighlightedService
|
||||
.first
|
||||
return nil unless software
|
||||
|
||||
releases = Release.where(software_id: software.id).to_a
|
||||
releases = Release.includes(:downloads).where(software_id: software.id).to_a
|
||||
build_response(software, releases)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
softwares = Software.includes(:releases, :external_links, :software_images).all
|
||||
softwares = Software.includes(releases: :downloads).includes(:external_links, :software_images).all
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,8 +9,11 @@ Rails.application.routes.draw do
|
||||
get "members", to: "members#index"
|
||||
get "image/:id", to: "images#show"
|
||||
get "wiki/pages", to: "wiki#index"
|
||||
get "rss/blog", to: "rss#blog"
|
||||
get "rss/releases", to: "rss#releases"
|
||||
end
|
||||
|
||||
get "download", to: "downloads#show"
|
||||
get "update", to: "update#update"
|
||||
get "file/*path", to: "files#show", format: false
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateDownloads < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :downloads, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.string :file_path, null: false
|
||||
t.bigint :release_id, unsigned: true
|
||||
t.string :ip_address
|
||||
t.string :user_agent, limit: 500
|
||||
t.string :referer, limit: 500
|
||||
t.datetime :created_at, precision: 3
|
||||
t.datetime :updated_at, precision: 3
|
||||
|
||||
t.index :file_path
|
||||
t.index :release_id
|
||||
end
|
||||
|
||||
add_foreign_key :downloads, :releases, name: "fk_downloads_release" if table_exists?(:releases)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Teletype Games</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="Teletype Games Blog" href="/api/rss/blog" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Teletype Games Releases" href="/api/rss/releases" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
contact: 'Contact us',
|
||||
retro: 'Retro Mode',
|
||||
exitRetro: 'Exit Retro Mode',
|
||||
rss: 'RSS',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -120,6 +121,7 @@ export default {
|
||||
platform: 'Platform:',
|
||||
play: '▶ Play',
|
||||
moreInfo: 'ℹ More Info',
|
||||
downloads: 'downloads',
|
||||
},
|
||||
catalogShow: {
|
||||
back: '← Back to Catalog',
|
||||
@@ -142,6 +144,7 @@ export default {
|
||||
failedToLoad: 'Failed to load:',
|
||||
loading: 'Loading...',
|
||||
play: '▶ Play',
|
||||
downloads: 'downloads',
|
||||
},
|
||||
team: {
|
||||
title: 'Our Team',
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
contact: 'Kapcsolat',
|
||||
retro: 'Retró mód',
|
||||
exitRetro: 'Retró mód kikapcsolása',
|
||||
rss: 'RSS',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -120,6 +121,7 @@ export default {
|
||||
platform: 'Platform:',
|
||||
play: '▶ Játék',
|
||||
moreInfo: 'ℹ Részletek',
|
||||
downloads: 'letöltés',
|
||||
},
|
||||
catalogShow: {
|
||||
back: '← Vissza a katalógushoz',
|
||||
@@ -142,6 +144,7 @@ export default {
|
||||
failedToLoad: 'Nem sikerült betölteni:',
|
||||
loading: 'Betöltés...',
|
||||
play: '▶ Játék',
|
||||
downloads: 'letöltés',
|
||||
},
|
||||
team: {
|
||||
title: 'Csapatunk',
|
||||
|
||||
@@ -49,9 +49,20 @@
|
||||
</main>
|
||||
|
||||
<div class="retro-toggle-bar">
|
||||
<button class="retro-toggle-btn" @click="toggleRetroMode">
|
||||
🖥 {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
|
||||
</button>
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<button class="retro-toggle-btn" @click="toggleRetroMode">
|
||||
🖥 {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<a href="/api/rss/blog" target="_blank" class="hover:text-orange-400 transition-colors" title="Blog RSS">
|
||||
📡 Blog
|
||||
</a>
|
||||
<span class="text-gray-600">|</span>
|
||||
<a href="/api/rss/releases" target="_blank" class="hover:text-orange-400 transition-colors" title="Releases RSS">
|
||||
📡 Releases
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Release {
|
||||
cartridgePath?: string
|
||||
sourcePath?: string
|
||||
docsFolderPath?: string
|
||||
downloadCount?: number
|
||||
UpdatedAt: string
|
||||
}
|
||||
|
||||
@@ -30,4 +31,5 @@ export interface Software {
|
||||
export interface SoftwareEntry {
|
||||
software: Software
|
||||
releases: Release[]
|
||||
totalDownloads?: number
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
<RouterLink :to="`/catalog/${software.name}`" class="btn-info-sm">
|
||||
{{ t('catalog.moreInfo') }}
|
||||
</RouterLink>
|
||||
<span v-if="getTotalDownloads(software.name)" class="text-xs text-gray-400 font-medium ml-1">
|
||||
↓ {{ getTotalDownloads(software.name) }} {{ t('catalog.downloads') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +69,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import type { Software } from '../../lib/interfaces/software.interface'
|
||||
import type { Software, SoftwareEntry } from '../../lib/interfaces/software.interface'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -77,6 +80,11 @@ const store = useSoftwareStore()
|
||||
const { items: softwares } = storeToRefs(store)
|
||||
const { getLatestStable } = store
|
||||
|
||||
function getTotalDownloads(name: string): number | null {
|
||||
const entry = softwares.value.find((e: SoftwareEntry) => e.software.name === name)
|
||||
return entry?.totalDownloads || null
|
||||
}
|
||||
|
||||
function getDefaultImageUrl(sw: Software): string | null {
|
||||
if (sw.images?.length) {
|
||||
const def = sw.images.find(i => i.isDefault)
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-2 xl:grid-cols-4 gap-2 w-full md:w-auto">
|
||||
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap">{{ t('catalogShow.playNow') }}</a>
|
||||
<a v-if="latestStable.cartridgePath" :href="latestStable.cartridgePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="latestStable.sourcePath" :href="latestStable.sourcePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="latestStable.cartridgePath" :href="downloadUrl(latestStable.cartridgePath)" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="latestStable.sourcePath" :href="downloadUrl(latestStable.sourcePath)" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="latestStable.docsFolderPath" :href="latestStable.docsFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.docs') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,11 +119,14 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="release in stableReleases" :key="release.version" class="hover:bg-gray-50/50 transition-colors">
|
||||
<td class="px-8 py-4">
|
||||
<div class="font-bold text-gray-900 text-lg mb-2">{{ release.version }}</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-bold text-gray-900 text-lg">{{ release.version }}</span>
|
||||
<span v-if="release.downloadCount" class="text-xs text-gray-400 font-medium">↓ {{ release.downloadCount }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.play') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="release.sourcePath" :href="release.sourcePath" target="_blank" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="release.sourcePath" :href="downloadUrl(release.sourcePath)" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="release.docsFolderPath" :href="release.docsFolderPath" target="_blank" class="text-yellow-600 hover:text-yellow-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.docs') }}</a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -139,7 +142,7 @@
|
||||
<div class="font-bold text-yellow-800 text-lg mb-2">{{ release.version }}</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.play') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-4 text-gray-500 align-top pt-5">{{ formatDateTime(release.UpdatedAt) }}</td>
|
||||
@@ -164,8 +167,8 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm">{{ t('catalogShow.play') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="release.sourcePath" :href="release.sourcePath" target="_blank" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">{{ t('catalogShow.download') }}</a>
|
||||
<a v-if="release.sourcePath" :href="downloadUrl(release.sourcePath)" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">{{ t('catalogShow.source') }}</a>
|
||||
<a v-if="release.docsFolderPath" :href="release.docsFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-yellow-100 text-yellow-700 font-bold rounded-lg text-sm">{{ t('catalogShow.docs') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,6 +253,9 @@ const devReleases = computed(() =>
|
||||
const allReleases = computed(() => [...stableReleases.value, ...devReleases.value])
|
||||
const latestStable = computed(() => stableReleases.value[0] ?? null)
|
||||
|
||||
const downloadUrl = (path: string) =>
|
||||
`/download?path=${encodeURIComponent(path.replace('/file/', ''))}`
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.fetchAll()
|
||||
|
||||
Reference in New Issue
Block a user