Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5effd054ec | ||
|
|
d9f9e0c461 | ||
|
|
26a77d4c69 | ||
|
|
09ee91ad67 | ||
|
|
f4560c2292 | ||
|
|
bdbac80063 | ||
|
|
1cdd3fd708 | ||
|
|
972cc6e5d9 | ||
|
|
a600db4d06 | ||
|
|
93b37e4069 |
@@ -1,7 +1,5 @@
|
||||
# DiscourseRewind
|
||||
|
||||
Display stats on your last year of Discourse usage.
|
||||
## DEPRECATED
|
||||
|
||||
## Usage
|
||||
|
||||
Navigate to `/my/activity/rewind`
|
||||
This plugin has been moved into Discourse core as at https://github.com/discourse/discourse/pull/36409/
|
||||
|
||||
@@ -8,12 +8,13 @@ module ::DiscourseRewind
|
||||
|
||||
def show
|
||||
DiscourseRewind::FetchReports.call(service_params) do
|
||||
on_model_not_found(:year) { raise Discourse::NotFound }
|
||||
on_model_not_found(:user) { raise Discourse::NotFound }
|
||||
on_success do |reports:|
|
||||
@reports = reports
|
||||
render json: MultiJson.dump(reports), status: :ok
|
||||
on_model_not_found(:year) do
|
||||
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
|
||||
end
|
||||
on_model_not_found(:reports) do
|
||||
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
|
||||
end
|
||||
on_success { |reports:| render json: MultiJson.dump(reports), status: :ok }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# AI usage statistics from discourse-ai plugin
|
||||
# Shows total usage, favorite features, token consumption, etc.
|
||||
# Uses AiApiRequestStat for efficient aggregation queries
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class AiUsage < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_requests: 247,
|
||||
total_tokens: 156_890,
|
||||
request_tokens: 45_230,
|
||||
response_tokens: 111_660,
|
||||
feature_usage: {
|
||||
"chat_composer_helper" => 89,
|
||||
"post_summarizer" => 56,
|
||||
"semantic_search" => 42,
|
||||
"topic_gist" => 38,
|
||||
"similar_topics" => 22,
|
||||
},
|
||||
model_usage: {
|
||||
"gpt-4" => 123,
|
||||
"claude-3-5-sonnet" => 89,
|
||||
"gpt-3.5-turbo" => 35,
|
||||
},
|
||||
success_rate: 94.7,
|
||||
},
|
||||
identifier: "ai-usage",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
return if !enabled?
|
||||
|
||||
base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date)
|
||||
|
||||
# Get aggregated stats in a single query
|
||||
stats =
|
||||
base_query.select(
|
||||
"COALESCE(SUM(usage_count), 0) as total_requests",
|
||||
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
|
||||
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
|
||||
"COALESCE(SUM(CASE WHEN response_tokens > 0 THEN usage_count ELSE 0 END), 0) as successful_requests",
|
||||
).take
|
||||
|
||||
return if stats.total_requests == 0
|
||||
|
||||
total_tokens = stats.total_request_tokens + stats.total_response_tokens
|
||||
success_rate =
|
||||
(
|
||||
if stats.total_requests > 0
|
||||
(stats.successful_requests.to_f / stats.total_requests * 100).round(1)
|
||||
else
|
||||
0
|
||||
end
|
||||
)
|
||||
|
||||
# Most used features (top 5)
|
||||
feature_usage =
|
||||
base_query
|
||||
.group(:feature_name)
|
||||
.order("SUM(usage_count) DESC")
|
||||
.limit(5)
|
||||
.pluck(:feature_name, Arel.sql("SUM(usage_count)"))
|
||||
.to_h
|
||||
|
||||
# Most used AI model (top 5)
|
||||
model_usage =
|
||||
base_query
|
||||
.where.not(language_model: nil)
|
||||
.group(:language_model)
|
||||
.order("SUM(usage_count) DESC")
|
||||
.limit(5)
|
||||
.pluck(:language_model, Arel.sql("SUM(usage_count)"))
|
||||
.to_h
|
||||
|
||||
{
|
||||
data: {
|
||||
total_requests: stats.total_requests,
|
||||
total_tokens: total_tokens,
|
||||
request_tokens: stats.total_request_tokens,
|
||||
response_tokens: stats.total_response_tokens,
|
||||
feature_usage: feature_usage,
|
||||
model_usage: model_usage,
|
||||
success_rate: success_rate,
|
||||
},
|
||||
identifier: "ai-usage",
|
||||
}
|
||||
end
|
||||
|
||||
def enabled?
|
||||
defined?(AiApiRequestStat) && SiteSetting.discourse_ai_enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Assignment statistics using discourse-assign plugin data
|
||||
# Shows how many assignments, completed, pending, etc.
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class Assignments < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_assigned: 24,
|
||||
completed: 18,
|
||||
pending: 6,
|
||||
assigned_by_user: 15,
|
||||
completion_rate: 75.0,
|
||||
},
|
||||
identifier: "assignments",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
return if !enabled?
|
||||
|
||||
# Assignments made to the user
|
||||
assignments_scope =
|
||||
Assignment
|
||||
.where(assigned_to_id: user.id, assigned_to_type: "User")
|
||||
.where(created_at: date)
|
||||
|
||||
total_assigned = assignments_scope.count
|
||||
|
||||
# Completed assignments (topics that were assigned and then closed or unassigned)
|
||||
completed_count =
|
||||
assignments_scope
|
||||
.joins(:topic)
|
||||
.where(
|
||||
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
|
||||
)
|
||||
.distinct
|
||||
.count
|
||||
|
||||
# Currently pending (still open and assigned)
|
||||
pending_count =
|
||||
Assignment
|
||||
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
|
||||
.joins(:topic)
|
||||
.where(topics: { closed: false })
|
||||
.count
|
||||
|
||||
# Assignments made by the user to others
|
||||
assigned_by_user =
|
||||
Assignment
|
||||
.where(assigned_by_user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.count
|
||||
|
||||
{
|
||||
data: {
|
||||
total_assigned: total_assigned,
|
||||
completed: completed_count,
|
||||
pending: pending_count,
|
||||
assigned_by_user: assigned_by_user,
|
||||
completion_rate:
|
||||
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
|
||||
},
|
||||
identifier: "assignments",
|
||||
}
|
||||
end
|
||||
|
||||
def enabled?
|
||||
defined?(Assignment) && SiteSetting.assign_enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,16 +3,56 @@
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class BestPosts < BaseReport
|
||||
FakeData = {
|
||||
data: [
|
||||
{
|
||||
post_number: 5,
|
||||
topic_id: 42,
|
||||
like_count: 23,
|
||||
reply_count: 8,
|
||||
excerpt: "This is a great explanation of how ActiveRecord works under the hood...",
|
||||
},
|
||||
{
|
||||
post_number: 12,
|
||||
topic_id: 89,
|
||||
like_count: 19,
|
||||
reply_count: 5,
|
||||
excerpt:
|
||||
"Here's a comprehensive guide to testing Rails applications with RSpec and system tests...",
|
||||
},
|
||||
{
|
||||
post_number: 3,
|
||||
topic_id: 156,
|
||||
like_count: 15,
|
||||
reply_count: 12,
|
||||
excerpt:
|
||||
"The key to understanding PostgreSQL performance is looking at your query plans...",
|
||||
},
|
||||
],
|
||||
identifier: "best-posts",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
best_posts =
|
||||
Post
|
||||
.where(user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.where("post_number > 1")
|
||||
.order("like_count DESC NULLS LAST")
|
||||
.order("like_count DESC NULLS LAST, created_at ASC")
|
||||
.limit(3)
|
||||
.pluck(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
|
||||
.select(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
|
||||
.map do |post|
|
||||
{
|
||||
post_number: post.post_number,
|
||||
topic_id: post.topic_id,
|
||||
like_count: post.like_count,
|
||||
reply_count: post.reply_count,
|
||||
excerpt:
|
||||
post.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
|
||||
}
|
||||
end
|
||||
|
||||
{ data: best_posts, identifier: "best-posts" }
|
||||
end
|
||||
|
||||
@@ -3,7 +3,32 @@
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class BestTopics < BaseReport
|
||||
FakeData = {
|
||||
data: [
|
||||
{
|
||||
topic_id: 1,
|
||||
title: "How to get started with Rails",
|
||||
excerpt: "A comprehensive guide to getting started with Ruby on Rails...",
|
||||
yearly_score: 42.5,
|
||||
},
|
||||
{
|
||||
topic_id: 2,
|
||||
title: "Best practices for database optimization",
|
||||
excerpt: "Learn how to optimize your database queries for better performance...",
|
||||
yearly_score: 38.2,
|
||||
},
|
||||
{
|
||||
topic_id: 3,
|
||||
title: "Understanding ActiveRecord associations",
|
||||
excerpt: "Deep dive into has_many, belongs_to, and other associations...",
|
||||
yearly_score: 35.7,
|
||||
},
|
||||
],
|
||||
identifier: "best-topics",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
best_topics =
|
||||
TopTopic
|
||||
.includes(:topic)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Chat usage statistics
|
||||
# Shows message counts, favorite channels, DM activity, etc.
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class ChatUsage < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_messages: 342,
|
||||
favorite_channels: [
|
||||
{ channel_id: 1, channel_name: "general", message_count: 156 },
|
||||
{ channel_id: 2, channel_name: "tech-talk", message_count: 89 },
|
||||
{ channel_id: 3, channel_name: "random", message_count: 45 },
|
||||
{ channel_id: 4, channel_name: "dev", message_count: 32 },
|
||||
{ channel_id: 5, channel_name: "announcements", message_count: 12 },
|
||||
],
|
||||
dm_message_count: 87,
|
||||
unique_dm_channels: 12,
|
||||
messages_with_reactions: 42,
|
||||
total_reactions_received: 156,
|
||||
avg_message_length: 78.5,
|
||||
},
|
||||
identifier: "chat-usage",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
return if !enabled?
|
||||
|
||||
messages =
|
||||
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
|
||||
|
||||
total_messages = messages.count
|
||||
return if total_messages == 0
|
||||
|
||||
# Get favorite channels (public channels)
|
||||
channel_usage =
|
||||
messages
|
||||
.joins(:chat_channel)
|
||||
.where(chat_channels: { type: "CategoryChannel" })
|
||||
.group("chat_channels.id", "chat_channels.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(5)
|
||||
.map do |(id, name), count|
|
||||
{ channel_id: id, channel_name: name, message_count: count }
|
||||
end
|
||||
|
||||
# DM statistics
|
||||
dm_message_count =
|
||||
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
|
||||
|
||||
# Unique DM conversations
|
||||
unique_dm_channels =
|
||||
messages
|
||||
.joins(:chat_channel)
|
||||
.where(chat_channels: { type: "DirectMessageChannel" })
|
||||
.distinct
|
||||
.count(:chat_channel_id)
|
||||
|
||||
# Messages with reactions received
|
||||
messages_with_reactions =
|
||||
Chat::MessageReaction
|
||||
.joins(:chat_message)
|
||||
.where(chat_messages: { user_id: user.id })
|
||||
.where(chat_messages: { created_at: date })
|
||||
.distinct
|
||||
.count(:chat_message_id)
|
||||
|
||||
# Total reactions received
|
||||
total_reactions_received =
|
||||
Chat::MessageReaction
|
||||
.joins(:chat_message)
|
||||
.where(chat_messages: { user_id: user.id })
|
||||
.where(chat_messages: { created_at: date })
|
||||
.count
|
||||
|
||||
# Average message length
|
||||
avg_message_length =
|
||||
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
|
||||
|
||||
{
|
||||
data: {
|
||||
total_messages: total_messages,
|
||||
favorite_channels: channel_usage,
|
||||
dm_message_count: dm_message_count,
|
||||
unique_dm_channels: unique_dm_channels,
|
||||
messages_with_reactions: messages_with_reactions,
|
||||
total_reactions_received: total_reactions_received,
|
||||
avg_message_length: avg_message_length,
|
||||
},
|
||||
identifier: "chat-usage",
|
||||
}
|
||||
end
|
||||
|
||||
def enabled?
|
||||
SiteSetting.chat_enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Find the user's most used GIFs in posts and chat
|
||||
# Ranks by usage count and engagement (likes/reactions)
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class FavoriteGifs < BaseReport
|
||||
GIF_URL_PATTERN =
|
||||
%r{
|
||||
https?://[^\s]+\.(?:gif|gifv)
|
||||
|
|
||||
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
||||
|
|
||||
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
|
||||
}ix
|
||||
MAX_RESULTS = 5
|
||||
|
||||
FakeData = {
|
||||
data: {
|
||||
favorite_gifs: [
|
||||
{
|
||||
url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif",
|
||||
usage_count: 12,
|
||||
likes: 45,
|
||||
reactions: 23,
|
||||
},
|
||||
{
|
||||
url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif",
|
||||
usage_count: 8,
|
||||
likes: 32,
|
||||
reactions: 18,
|
||||
},
|
||||
{
|
||||
url: "https://media1.tenor.com/m/XnODae53zvYAAAAd/joke-stickfigure.gif",
|
||||
usage_count: 7,
|
||||
likes: 28,
|
||||
reactions: 15,
|
||||
},
|
||||
{
|
||||
url: "https://c.tenor.com/tX_T48A14BwAAAAd/khaby-really.gif",
|
||||
usage_count: 5,
|
||||
likes: 20,
|
||||
reactions: 12,
|
||||
},
|
||||
{
|
||||
url: "https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif",
|
||||
usage_count: 4,
|
||||
likes: 15,
|
||||
reactions: 8,
|
||||
},
|
||||
],
|
||||
total_gif_usage: 36,
|
||||
},
|
||||
identifier: "favorite-gifs",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
gif_data = {}
|
||||
|
||||
# Get GIFs from posts
|
||||
post_gifs = extract_gifs_from_posts
|
||||
post_gifs.each do |url, data|
|
||||
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
|
||||
gif_data[url][:usage_count] += data[:count]
|
||||
gif_data[url][:likes] += data[:likes]
|
||||
end
|
||||
|
||||
# Get GIFs from chat messages if chat is enabled
|
||||
if SiteSetting.chat_enabled
|
||||
chat_gifs = extract_gifs_from_chat
|
||||
chat_gifs.each do |url, data|
|
||||
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
|
||||
gif_data[url][:usage_count] += data[:count]
|
||||
gif_data[url][:reactions] += data[:reactions]
|
||||
end
|
||||
end
|
||||
|
||||
return if gif_data.empty?
|
||||
|
||||
# Sort by engagement score (usage * 10 + likes + reactions)
|
||||
sorted_gifs =
|
||||
gif_data
|
||||
.values
|
||||
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
|
||||
.first(MAX_RESULTS)
|
||||
|
||||
{
|
||||
data: {
|
||||
favorite_gifs: sorted_gifs,
|
||||
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
|
||||
},
|
||||
identifier: "favorite-gifs",
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_gifs_from_posts
|
||||
gif_usage = {}
|
||||
|
||||
posts =
|
||||
Post
|
||||
.where(user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.where("raw ~* ?", gif_sql_pattern)
|
||||
.select(:id, :raw, :like_count)
|
||||
|
||||
posts.each do |post|
|
||||
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
|
||||
gif_urls.each do |url|
|
||||
gif_usage[url] ||= { count: 0, likes: 0 }
|
||||
gif_usage[url][:count] += 1
|
||||
gif_usage[url][:likes] += post.like_count || 0
|
||||
end
|
||||
end
|
||||
|
||||
gif_usage
|
||||
end
|
||||
|
||||
def extract_gifs_from_chat
|
||||
gif_usage = {}
|
||||
|
||||
messages =
|
||||
Chat::Message
|
||||
.where(user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.where("message ~* ?", gif_sql_pattern)
|
||||
.select(:id, :message)
|
||||
|
||||
messages.each do |message|
|
||||
gif_urls =
|
||||
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
|
||||
gif_urls.each do |url|
|
||||
gif_usage[url] ||= { count: 0, reactions: 0 }
|
||||
gif_usage[url][:count] += 1
|
||||
|
||||
# Count reactions on this message
|
||||
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
|
||||
gif_usage[url][:reactions] += reaction_count
|
||||
end
|
||||
end
|
||||
|
||||
gif_usage
|
||||
end
|
||||
|
||||
def gif_sql_pattern
|
||||
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
|
||||
end
|
||||
|
||||
def content_gif_url?(url)
|
||||
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
|
||||
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
|
||||
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,26 @@ module DiscourseRewind
|
||||
LIKE_SCORE = 1
|
||||
REPLY_SCORE = 10
|
||||
|
||||
FakeData = {
|
||||
data: {
|
||||
fbff: {
|
||||
id: 2,
|
||||
username: "codingpal",
|
||||
name: "Coding Pal",
|
||||
avatar_template: "/letter_avatar_proxy/v4/letter/c/3be4f8/{size}.png",
|
||||
},
|
||||
yourself: {
|
||||
id: 1,
|
||||
username: "you",
|
||||
name: "You",
|
||||
avatar_template: "/letter_avatar_proxy/v4/letter/y/f05b48/{size}.png",
|
||||
},
|
||||
},
|
||||
identifier: "fbff",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
most_liked_users =
|
||||
like_query(date)
|
||||
.where(acting_user_id: user.id)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Invite statistics
|
||||
# Shows how many users this user invited and the impact of those invitees
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class Invites < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_invites: 18,
|
||||
redeemed_count: 12,
|
||||
redemption_rate: 66.7,
|
||||
invitee_post_count: 145,
|
||||
invitee_topic_count: 23,
|
||||
invitee_like_count: 89,
|
||||
avg_trust_level: 1.8,
|
||||
most_active_invitee: {
|
||||
id: 42,
|
||||
username: "newbie_123",
|
||||
name: "New User",
|
||||
avatar_template: "/letter_avatar_proxy/v4/letter/n/8c91d9/{size}.png",
|
||||
},
|
||||
},
|
||||
identifier: "invites",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
# Get all invites created by this user in the date range
|
||||
invites = Invite.where(invited_by_id: user.id).where(created_at: date)
|
||||
|
||||
total_invites = invites.count
|
||||
return if total_invites == 0
|
||||
|
||||
# Redeemed invites (users who actually joined)
|
||||
redeemed_count = invites.where("redemption_count > 0").count
|
||||
|
||||
# Get the users who were invited (via InvitedUser or redeemed invites)
|
||||
invited_user_ids = InvitedUser.where(invite: invites).pluck(:user_id).compact
|
||||
|
||||
invited_users = User.where(id: invited_user_ids)
|
||||
|
||||
# Calculate impact of invitees
|
||||
invitee_post_count =
|
||||
Post.where(user_id: invited_user_ids).where(created_at: date).where(deleted_at: nil).count
|
||||
|
||||
invitee_topic_count =
|
||||
Topic
|
||||
.where(user_id: invited_user_ids)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.count
|
||||
|
||||
invitee_like_count =
|
||||
UserAction
|
||||
.where(user_id: invited_user_ids)
|
||||
.where(action_type: UserAction::LIKE)
|
||||
.where(created_at: date)
|
||||
.count
|
||||
|
||||
# Calculate average trust level of invitees
|
||||
avg_trust_level = invited_users.average(:trust_level)&.to_f&.round(1) || 0
|
||||
|
||||
# Most active invitee
|
||||
most_active_invitee = nil
|
||||
if invited_user_ids.any?
|
||||
most_active_id =
|
||||
Post
|
||||
.where(user_id: invited_user_ids)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.group(:user_id)
|
||||
.count
|
||||
.max_by { |_, count| count }
|
||||
&.first
|
||||
|
||||
if most_active_id
|
||||
most_active_user = User.find_by(id: most_active_id)
|
||||
most_active_invitee =
|
||||
BasicUserSerializer.new(most_active_user, root: false).as_json if most_active_user
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
data: {
|
||||
total_invites: total_invites,
|
||||
redeemed_count: redeemed_count,
|
||||
redemption_rate:
|
||||
total_invites > 0 ? (redeemed_count.to_f / total_invites * 100).round(1) : 0,
|
||||
invitee_post_count: invitee_post_count,
|
||||
invitee_topic_count: invitee_topic_count,
|
||||
invitee_like_count: invitee_like_count,
|
||||
avg_trust_level: avg_trust_level,
|
||||
most_active_invitee: most_active_invitee,
|
||||
},
|
||||
identifier: "invites",
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+12
-9
@@ -3,34 +3,37 @@
|
||||
# Topics visited grouped by category
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class FavoriteCategories < BaseReport
|
||||
class MostViewedCategories < BaseReport
|
||||
FakeData = {
|
||||
data: [
|
||||
{ category_id: 1, name: "cats" },
|
||||
{ category_id: 2, name: "dogs" },
|
||||
{ category_id: 3, name: "countries" },
|
||||
{ category_id: 4, name: "management" },
|
||||
{ category_id: 5, name: "things" },
|
||||
],
|
||||
identifier: "favorite-categories",
|
||||
identifier: "most-viewed-categories",
|
||||
}
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
|
||||
favorite_categories =
|
||||
most_viewed_categories =
|
||||
TopicViewItem
|
||||
.joins(:topic)
|
||||
.joins("INNER JOIN categories ON categories.id = topics.category_id")
|
||||
.where(user: user)
|
||||
.where(viewed_at: date)
|
||||
.where(categories: { id: user.guardian.allowed_category_ids })
|
||||
.where(
|
||||
user: user,
|
||||
viewed_at: date,
|
||||
categories: {
|
||||
id: user.guardian.allowed_category_ids,
|
||||
},
|
||||
)
|
||||
.group("categories.id, categories.name")
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(5)
|
||||
.limit(4)
|
||||
.pluck("categories.id, categories.name")
|
||||
.map { |category_id, name| { category_id: category_id, name: name } }
|
||||
|
||||
{ data: favorite_categories, identifier: "favorite-categories" }
|
||||
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
|
||||
end
|
||||
end
|
||||
end
|
||||
+6
-9
@@ -3,36 +3,33 @@
|
||||
# Topics visited grouped by tag
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class FavoriteTags < BaseReport
|
||||
class MostViewedTags < BaseReport
|
||||
FakeData = {
|
||||
data: [
|
||||
{ tag_id: 1, name: "cats" },
|
||||
{ tag_id: 2, name: "dogs" },
|
||||
{ tag_id: 3, name: "countries" },
|
||||
{ tag_id: 4, name: "management" },
|
||||
{ tag_id: 5, name: "things" },
|
||||
],
|
||||
identifier: "favorite-tags",
|
||||
identifier: "most-viewed-tags",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
|
||||
favorite_tags =
|
||||
most_viewed_tags =
|
||||
TopicViewItem
|
||||
.joins(:topic)
|
||||
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
|
||||
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
|
||||
.where(user: user)
|
||||
.where(viewed_at: date)
|
||||
.where(tags: { id: Tag.visible(user.guardian).pluck(:id) })
|
||||
.where(user: user, viewed_at: date, tags: { id: Tag.visible(user.guardian).pluck(:id) })
|
||||
.group("tags.id, tags.name")
|
||||
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
|
||||
.limit(5)
|
||||
.limit(4)
|
||||
.pluck("tags.id, tags.name")
|
||||
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
||||
|
||||
{ data: favorite_tags, identifier: "favorite-tags" }
|
||||
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Tracks how much this user interacted with new users (created this year)
|
||||
# Shows veteran mentorship and community building behavior
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class NewUserInteractions < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_interactions: 127,
|
||||
likes_given: 45,
|
||||
replies_to_new_users: 62,
|
||||
mentions_to_new_users: 20,
|
||||
topics_with_new_users: 8,
|
||||
unique_new_users: 24,
|
||||
new_users_count: 156,
|
||||
},
|
||||
identifier: "new-user-interactions",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
year_start = Date.new(date.first.year, 1, 1)
|
||||
|
||||
# Find users who created accounts this year
|
||||
new_user_ids =
|
||||
User
|
||||
.real
|
||||
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
|
||||
.where("id != ?", user.id)
|
||||
.pluck(:id)
|
||||
|
||||
return if new_user_ids.empty?
|
||||
|
||||
# Count likes given to new users
|
||||
liked_user_ids =
|
||||
UserAction
|
||||
.where(
|
||||
acting_user_id: user.id,
|
||||
user_id: new_user_ids,
|
||||
action_type: UserAction::WAS_LIKED,
|
||||
)
|
||||
.where(created_at: date)
|
||||
.distinct
|
||||
.pluck(:user_id)
|
||||
|
||||
# Count replies to new users' posts
|
||||
replied_user_ids =
|
||||
Post
|
||||
.joins(
|
||||
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
|
||||
)
|
||||
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
|
||||
.where("parent_posts.user_id": new_user_ids)
|
||||
.distinct
|
||||
.pluck("parent_posts.user_id")
|
||||
|
||||
# Count direct mentions to new users
|
||||
mentioned_user_ids =
|
||||
Post
|
||||
.joins(
|
||||
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
|
||||
)
|
||||
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
|
||||
.where(user_actions: { user_id: new_user_ids })
|
||||
.distinct
|
||||
.pluck("user_actions.user_id")
|
||||
|
||||
# Unique new users interacted with
|
||||
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
|
||||
|
||||
return if unique_new_users == 0
|
||||
|
||||
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -85,7 +85,7 @@ module DiscourseRewind
|
||||
end
|
||||
|
||||
def sort_and_limit(reactions)
|
||||
reactions.sort_by { |_, v| -v }.first(5).reverse.to_h
|
||||
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Time of day activity analysis
|
||||
# Shows when a user is most active (considering their timezone)
|
||||
# Determines if they are a night owl or early bird
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class TimeOfDayActivity < BaseReport
|
||||
EARLY_BIRD_THRESHOLD = 6..9
|
||||
NIGHT_OWL_THRESHOLD_PM = 22..23
|
||||
NIGHT_OWL_THRESHOLD_AM = 0..2
|
||||
|
||||
FakeData = {
|
||||
data: {
|
||||
activity_by_hour: {
|
||||
0 => 12,
|
||||
1 => 8,
|
||||
2 => 5,
|
||||
3 => 2,
|
||||
4 => 1,
|
||||
5 => 3,
|
||||
6 => 8,
|
||||
7 => 15,
|
||||
8 => 25,
|
||||
9 => 32,
|
||||
10 => 28,
|
||||
11 => 24,
|
||||
12 => 22,
|
||||
13 => 20,
|
||||
14 => 26,
|
||||
15 => 30,
|
||||
16 => 28,
|
||||
17 => 22,
|
||||
18 => 18,
|
||||
19 => 16,
|
||||
20 => 14,
|
||||
21 => 18,
|
||||
22 => 22,
|
||||
23 => 15,
|
||||
},
|
||||
most_active_hour: 9,
|
||||
personality: {
|
||||
type: "early_bird",
|
||||
percentage: 28.5,
|
||||
},
|
||||
total_activities: 414,
|
||||
},
|
||||
identifier: "time-of-day-activity",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
# Get activity by hour of day (in user's timezone)
|
||||
activity_by_hour = get_activity_by_hour
|
||||
|
||||
return if activity_by_hour.empty?
|
||||
|
||||
total_activities = activity_by_hour.values.sum
|
||||
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
|
||||
personality = determine_personality(activity_by_hour, total_activities)
|
||||
|
||||
{
|
||||
data: {
|
||||
activity_by_hour: activity_by_hour,
|
||||
most_active_hour: most_active_hour,
|
||||
personality: personality,
|
||||
total_activities: total_activities,
|
||||
},
|
||||
identifier: "time-of-day-activity",
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_activity_by_hour
|
||||
# Get user timezone offset
|
||||
user_timezone = user.user_option&.timezone || "UTC"
|
||||
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
|
||||
hour_extract_sql =
|
||||
Arel.sql(
|
||||
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
|
||||
)
|
||||
|
||||
# Initialize hash with all hours
|
||||
activity = (0..23).to_h { |hour| [hour, 0] }
|
||||
|
||||
# Posts created
|
||||
post_hours =
|
||||
Post
|
||||
.where(user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.pluck(hour_extract_sql)
|
||||
.tally
|
||||
|
||||
# User visits (page views)
|
||||
visit_hours =
|
||||
UserHistory
|
||||
.where(acting_user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(action: UserHistory.actions[:page_view])
|
||||
.pluck(hour_extract_sql)
|
||||
.tally
|
||||
|
||||
# Chat messages if chat is enabled
|
||||
if SiteSetting.chat_enabled
|
||||
chat_hours =
|
||||
Chat::Message
|
||||
.where(user_id: user.id)
|
||||
.where(created_at: date)
|
||||
.where(deleted_at: nil)
|
||||
.pluck(hour_extract_sql)
|
||||
.tally
|
||||
|
||||
chat_hours.each { |hour, count| activity[hour] += count }
|
||||
end
|
||||
|
||||
post_hours.each { |hour, count| activity[hour] += count }
|
||||
visit_hours.each { |hour, count| activity[hour] += count }
|
||||
|
||||
activity
|
||||
end
|
||||
|
||||
def determine_personality(activity_by_hour, total_activities)
|
||||
return nil if total_activities == 0
|
||||
|
||||
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
|
||||
night_owl_activity =
|
||||
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
|
||||
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
|
||||
|
||||
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
|
||||
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
|
||||
|
||||
if early_bird_percentage > 20
|
||||
{ type: "early_bird", percentage: early_bird_percentage }
|
||||
elsif night_owl_percentage > 20
|
||||
{ type: "night_owl", percentage: night_owl_percentage }
|
||||
else
|
||||
{ type: "balanced", percentage: 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,6 @@ module DiscourseRewind
|
||||
{ word: "you", score: 80 },
|
||||
{ word: "overachieved", score: 70 },
|
||||
{ word: "assume", score: 60 },
|
||||
{ word: "week", score: 50 },
|
||||
],
|
||||
identifier: "top-words",
|
||||
}
|
||||
@@ -79,7 +78,13 @@ module DiscourseRewind
|
||||
LIMIT 100
|
||||
SQL
|
||||
|
||||
word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
|
||||
word_score =
|
||||
words
|
||||
.map do |word_data|
|
||||
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
|
||||
end
|
||||
.sort_by! { |w| -w[:score] }
|
||||
.take(5)
|
||||
|
||||
{ data: word_score, identifier: "top-words" }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class WritingAnalysis < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
total_words: 45_230,
|
||||
total_posts: 197,
|
||||
average_post_length: 230,
|
||||
readability_score: 65.4,
|
||||
},
|
||||
identifier: "writing-analysis",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if Rails.env.development?
|
||||
|
||||
total_words =
|
||||
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||
SELECT SUM(word_count) FROM posts
|
||||
WHERE user_id = :user_id
|
||||
AND created_at BETWEEN :date_start AND :date_end
|
||||
AND deleted_at IS NULL
|
||||
SQL
|
||||
|
||||
post_count =
|
||||
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||
SELECT COUNT(*) FROM posts
|
||||
WHERE user_id = :user_id
|
||||
AND created_at BETWEEN :date_start AND :date_end
|
||||
AND deleted_at IS NULL
|
||||
SQL
|
||||
|
||||
average_post_length =
|
||||
post_count.first > 0 ? (total_words.first.to_f / post_count.first).round(2) : 0
|
||||
|
||||
# Calculated using the Flesch Reading Ease formula,
|
||||
# with an approximation for syllables since this can
|
||||
# be tricky to get right in SQL.
|
||||
#
|
||||
# Tries to handle short sentences or ones without delmiters
|
||||
# and ending with emojis by treating them as a single sentence.
|
||||
readability_score =
|
||||
DB.query_single(<<~SQL, user_id: user.id, start: date.first, end: date.last)
|
||||
WITH cleaned AS (
|
||||
SELECT
|
||||
p.id AS post_id,
|
||||
p.user_id,
|
||||
p.created_at,
|
||||
p.word_count,
|
||||
regexp_replace(p.cooked, '<[^>]+>', ' ', 'g') AS plain
|
||||
FROM posts p
|
||||
WHERE p.user_id = :user_id
|
||||
AND p.created_at BETWEEN :start AND :end
|
||||
),
|
||||
metrics AS (
|
||||
SELECT
|
||||
post_id,
|
||||
user_id,
|
||||
created_at,
|
||||
plain,
|
||||
word_count AS words,
|
||||
regexp_count(plain, '[.!?;:](\s|$)') AS sentences_raw,
|
||||
regexp_count(lower(plain), '[aeiouy]+') AS syllables
|
||||
FROM cleaned
|
||||
),
|
||||
scores AS (
|
||||
SELECT
|
||||
post_id,
|
||||
user_id,
|
||||
created_at,
|
||||
words,
|
||||
syllables,
|
||||
plain,
|
||||
|
||||
CASE
|
||||
WHEN sentences_raw = 0 AND words > 5 THEN 1
|
||||
ELSE sentences_raw
|
||||
END AS sentences_fixed,
|
||||
|
||||
-- Flesch Reading Ease formula
|
||||
CASE
|
||||
WHEN words = 0 THEN NULL
|
||||
WHEN (CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END) = 0 THEN NULL
|
||||
ELSE (
|
||||
206.835
|
||||
- 1.015 * (
|
||||
words::float /
|
||||
(CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END)
|
||||
)
|
||||
- 84.6 * (syllables::float / words)
|
||||
)
|
||||
END AS readability_score
|
||||
|
||||
FROM metrics
|
||||
)
|
||||
SELECT AVG(readability_score) AS avg_readability_score
|
||||
FROM scores
|
||||
GROUP BY user_id;
|
||||
SQL
|
||||
|
||||
{
|
||||
data: {
|
||||
total_words: total_words.first,
|
||||
total_posts: post_count.first,
|
||||
average_post_length: average_post_length,
|
||||
readability_score: readability_score.first,
|
||||
},
|
||||
identifier: "writing-analysis",
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,8 @@ module DiscourseRewind
|
||||
#
|
||||
# @example
|
||||
# ::DiscourseRewind::Rewind::Fetch.call(
|
||||
# guardian: guardian
|
||||
# guardian: guardian,
|
||||
# params: { year: 2023, username: 'codinghorror' }
|
||||
# )
|
||||
#
|
||||
class FetchReports
|
||||
@@ -24,26 +25,29 @@ module DiscourseRewind
|
||||
REPORTS = [
|
||||
Action::TopWords,
|
||||
Action::ReadingTime,
|
||||
Action::WritingAnalysis,
|
||||
Action::Reactions,
|
||||
Action::Fbff,
|
||||
Action::FavoriteTags,
|
||||
Action::FavoriteCategories,
|
||||
Action::MostViewedTags,
|
||||
Action::MostViewedCategories,
|
||||
Action::BestTopics,
|
||||
Action::BestPosts,
|
||||
Action::ActivityCalendar,
|
||||
Action::TimeOfDayActivity,
|
||||
Action::NewUserInteractions,
|
||||
Action::ChatUsage,
|
||||
Action::AiUsage,
|
||||
Action::FavoriteGifs,
|
||||
Action::Assignments,
|
||||
Action::Invites,
|
||||
]
|
||||
|
||||
model :year
|
||||
model :user
|
||||
model :date
|
||||
model :reports
|
||||
|
||||
private
|
||||
|
||||
def fetch_user(guardian:)
|
||||
User.find_by_username(guardian.user.username)
|
||||
end
|
||||
|
||||
def fetch_year
|
||||
current_date = Time.zone.now
|
||||
current_month = current_date.month
|
||||
@@ -55,7 +59,13 @@ module DiscourseRewind
|
||||
when 12
|
||||
current_year
|
||||
else
|
||||
false
|
||||
# Otherwise it's impossible to test in browser unless you're
|
||||
# in December or January
|
||||
if Rails.env.development?
|
||||
current_year
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,12 +73,13 @@ module DiscourseRewind
|
||||
Date.new(year).all_year
|
||||
end
|
||||
|
||||
def fetch_reports(date:, user:, guardian:, year:)
|
||||
def fetch_reports(date:, guardian:, year:)
|
||||
key = "rewind:#{guardian.user.username}:#{year}"
|
||||
reports = Discourse.redis.get(key)
|
||||
|
||||
if !reports
|
||||
reports = REPORTS.map { |report| report.call(date:, user:, guardian:) }
|
||||
reports =
|
||||
REPORTS.map { |report| report.call(date:, user: guardian.user, guardian:) }.compact
|
||||
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
|
||||
else
|
||||
reports = MultiJson.load(reports, symbolize_keys: true)
|
||||
|
||||
@@ -28,21 +28,47 @@ export default class ActivityCalendar extends Component {
|
||||
return moment().month(monthIndex).format("MMM");
|
||||
}
|
||||
|
||||
@action
|
||||
computeCellTitle(cell) {
|
||||
if (!cell || !cell.date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const date = moment(cell.date).format("LL");
|
||||
|
||||
if (cell.visited && cell.post_count === 0) {
|
||||
return i18n(
|
||||
"discourse_rewind.reports.activity_calendar.cell_title.visited_no_posts",
|
||||
{ date }
|
||||
);
|
||||
} else if (cell.post_count > 0) {
|
||||
return i18n(
|
||||
"discourse_rewind.reports.activity_calendar.cell_title.visited_with_posts",
|
||||
{ date, count: cell.post_count }
|
||||
);
|
||||
}
|
||||
|
||||
return i18n(
|
||||
"discourse_rewind.reports.activity_calendar.cell_title.no_activity",
|
||||
{ date }
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
computeClass(count) {
|
||||
if (!count) {
|
||||
return "-empty";
|
||||
return "--empty";
|
||||
} else if (count < 10) {
|
||||
return "-low";
|
||||
return "--low";
|
||||
} else if (count < 20) {
|
||||
return "-medium";
|
||||
return "--medium";
|
||||
} else {
|
||||
return "-high";
|
||||
return "--high";
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page -activity-calendar">
|
||||
<div class="rewind-report-page --activity-calendar">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.activity_calendar.title"
|
||||
}}</h2>
|
||||
@@ -107,7 +133,7 @@ export default class ActivityCalendar extends Component {
|
||||
{{#each row as |cell|}}
|
||||
<td
|
||||
data-date={{cell.date}}
|
||||
title={{cell.date}}
|
||||
title={{this.computeCellTitle cell}}
|
||||
class={{concatClass
|
||||
"rewind-calendar-cell"
|
||||
(this.computeClass cell.post_count)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { get } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { service } from "@ember/service";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class AiUsage extends Component {
|
||||
@service currentUser;
|
||||
|
||||
matrixInterval = null;
|
||||
|
||||
get totalRequests() {
|
||||
return this.args.report.data.total_requests ?? 0;
|
||||
}
|
||||
|
||||
get totalTokens() {
|
||||
return this.args.report.data.total_tokens ?? 0;
|
||||
}
|
||||
|
||||
get successRate() {
|
||||
return this.args.report.data.success_rate ?? 0;
|
||||
}
|
||||
|
||||
get featureUsage() {
|
||||
return Object.entries(this.args.report.data.feature_usage ?? {})
|
||||
.filter(([name]) => name && name.trim().length > 0)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
get modelUsage() {
|
||||
return Object.entries(this.args.report.data.model_usage ?? {})
|
||||
.filter(([name]) => name && name.trim().length > 0)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
@action
|
||||
formatFeatureName(featureName) {
|
||||
return featureName.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
@action
|
||||
setupMatrix(element) {
|
||||
const canvas = element;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = element.offsetWidth;
|
||||
canvas.height = element.offsetHeight;
|
||||
|
||||
const characters =
|
||||
"01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
|
||||
const fontSize = 14;
|
||||
const columns = Math.floor(canvas.width / fontSize);
|
||||
const drops = Array(columns).fill(1);
|
||||
|
||||
const draw = () => {
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = "#0f0";
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const char = characters[Math.floor(Math.random() * characters.length)];
|
||||
const x = i * fontSize;
|
||||
const y = drops[i] * fontSize;
|
||||
|
||||
ctx.fillText(char, x, y);
|
||||
|
||||
if (y > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
|
||||
drops[i]++;
|
||||
}
|
||||
};
|
||||
|
||||
this.matrixInterval = setInterval(draw, 33);
|
||||
}
|
||||
|
||||
@action
|
||||
cleanupMatrix() {
|
||||
if (this.matrixInterval) {
|
||||
clearInterval(this.matrixInterval);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --ai-usage">
|
||||
<div class="matrix-container">
|
||||
<canvas
|
||||
class="matrix-rain"
|
||||
{{didInsert this.setupMatrix}}
|
||||
{{willDestroy this.cleanupMatrix}}
|
||||
></canvas>
|
||||
|
||||
<div class="matrix-content">
|
||||
<h2 class="matrix-title">
|
||||
<div class="matrix-subhead">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.ai_usage.wake_up"
|
||||
username=this.currentUser.username
|
||||
}}
|
||||
</div>
|
||||
{{i18n "discourse_rewind.reports.ai_usage.system_title"}}
|
||||
</h2>
|
||||
|
||||
<div class="matrix-stats">
|
||||
<div class="matrix-stat">
|
||||
<div class="matrix-stat__label">
|
||||
{{i18n "discourse_rewind.reports.ai_usage.total_requests"}}
|
||||
</div>
|
||||
<div class="matrix-stat__value">
|
||||
{{number this.totalRequests}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="matrix-stat">
|
||||
<div class="matrix-stat__label">
|
||||
{{i18n "discourse_rewind.reports.ai_usage.total_tokens"}}
|
||||
</div>
|
||||
<div class="matrix-stat__value">{{number this.totalTokens}}</div>
|
||||
</div>
|
||||
|
||||
<div class="matrix-stat">
|
||||
<div class="matrix-stat__label">
|
||||
{{i18n "discourse_rewind.reports.ai_usage.success_rate"}}
|
||||
</div>
|
||||
<div class="matrix-stat__value">
|
||||
<span class="number">
|
||||
{{this.successRate}}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.featureUsage.length}}
|
||||
<div class="matrix-section">
|
||||
<div class="matrix-section__title">>
|
||||
{{i18n "discourse_rewind.reports.ai_usage.section_features"}}
|
||||
</div>
|
||||
<div class="matrix-list">
|
||||
{{#each this.featureUsage as |entry|}}
|
||||
<div class="matrix-list__item">
|
||||
<span class="matrix-list__name">
|
||||
{{this.formatFeatureName (get entry "0")}}
|
||||
</span>
|
||||
<span class="matrix-list__count">{{get entry "1"}}</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.modelUsage.length}}
|
||||
<div class="matrix-section">
|
||||
<div class="matrix-section__title">>
|
||||
{{i18n "discourse_rewind.reports.ai_usage.section_models"}}
|
||||
</div>
|
||||
<div class="matrix-list">
|
||||
{{#each this.modelUsage as |entry|}}
|
||||
<div class="matrix-list__item">
|
||||
<span class="matrix-list__name">{{get entry "0"}}</span>
|
||||
<span class="matrix-list__count">{{get entry "1"}}</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const Assignments = <template>
|
||||
<div class="rewind-report-page --assignments">
|
||||
<div class="sticky-board">
|
||||
<div class="sticky-note --yellow --rotate-left">
|
||||
<div class="sticky-note__content">
|
||||
<div class="sticky-note__title">
|
||||
{{i18n "discourse_rewind.reports.assignments.completed"}}
|
||||
</div>
|
||||
<div class="sticky-note__value">
|
||||
{{number @report.data.completed}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky-note --pink --rotate-right">
|
||||
<div class="sticky-note__content">
|
||||
<div class="sticky-note__title">
|
||||
{{i18n "discourse_rewind.reports.assignments.pending"}}
|
||||
</div>
|
||||
<div class="sticky-note__value">{{number @report.data.pending}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky-note --blue --rotate-left-small">
|
||||
<div class="sticky-note__content">
|
||||
<div class="sticky-note__title">
|
||||
{{i18n "discourse_rewind.reports.assignments.total_assigned"}}
|
||||
</div>
|
||||
<div class="sticky-note__value">
|
||||
{{number @report.data.total_assigned}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky-note --green --rotate-right-small">
|
||||
<div class="sticky-note__content">
|
||||
<div class="sticky-note__title">
|
||||
{{i18n "discourse_rewind.reports.assignments.assigned_by_user"}}
|
||||
</div>
|
||||
<div class="sticky-note__value">
|
||||
{{number @report.data.assigned_by_user}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky-note --orange">
|
||||
<div class="sticky-note__content">
|
||||
<div class="sticky-note__title">
|
||||
{{i18n "discourse_rewind.reports.assignments.completion_rate"}}
|
||||
</div>
|
||||
<div
|
||||
class="sticky-note__value"
|
||||
>{{@report.data.completion_rate}}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default Assignments;
|
||||
@@ -1,36 +1,41 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { get } from "@ember/object";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class BestPosts extends Component {
|
||||
rank(idx) {
|
||||
return idx + 1;
|
||||
rankClass(idx) {
|
||||
return `rank-${idx + 1}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page -best-posts">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
<div class="rewind-report-page --best-posts">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.best_posts.title"
|
||||
[email protected]
|
||||
}}</h2>
|
||||
}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |post idx|}}
|
||||
<div class={{concat "rewind-card" " rank-" (this.rank idx)}}>
|
||||
<span class="best-posts -rank"></span>
|
||||
<span class="best-posts -rank"></span>
|
||||
<div class="best-posts__post">{{htmlSafe (get post "5")}}</div>
|
||||
<div class={{concatClass "rewind-card" (this.rankClass idx)}}>
|
||||
<span class="best-posts --rank"></span>
|
||||
<span class="best-posts --rank"></span>
|
||||
<div class="best-posts__post">
|
||||
<p>{{htmlSafe post.excerpt}}</p>
|
||||
</div>
|
||||
<div class="best-posts__metadata">
|
||||
<span class="best-posts__likes">
|
||||
{{icon "heart"}}{{htmlSafe (get post "2")}}</span>
|
||||
{{icon "heart"}}{{post.like_count}}
|
||||
</span>
|
||||
<span class="best-posts__replies">
|
||||
{{icon "comment"}}{{htmlSafe (get post "3")}}</span>
|
||||
<a href="/t/{{get post '1'}}/{{get post '0'}}">{{i18n
|
||||
"discourse_rewind.reports.best_posts.view_post"
|
||||
}}</a>
|
||||
{{icon "comment"}}{{post.reply_count}}
|
||||
</span>
|
||||
<a href="/t/{{post.topic_id}}/{{post.post_number}}">
|
||||
{{i18n "discourse_rewind.reports.best_posts.view_post"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class BestTopics extends Component {
|
||||
rank(idx) {
|
||||
return idx + 1;
|
||||
rankClass(idx) {
|
||||
return `rank-${idx + 1}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page -best-topics">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
<div class="rewind-report-page --best-topics">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.best_topics.title"
|
||||
[email protected]
|
||||
}}</h2>
|
||||
}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
<div class="rewind-card">
|
||||
{{#each @report.data as |topic idx|}}
|
||||
<a
|
||||
href={{concat "/t/-/" topic.topic_id}}
|
||||
class={{concat "best-topics__topic" " rank-" (this.rank idx)}}
|
||||
href={{getURL (concat "/t/-/" topic.topic_id)}}
|
||||
class={{concatClass "best-topics__topic" (this.rankClass idx)}}
|
||||
>
|
||||
<span class="best-topics -rank"></span>
|
||||
<span class="best-topics -rank"></span>
|
||||
<span class="best-topics --rank"></span>
|
||||
<span class="best-topics --rank"></span>
|
||||
<h2 class="best-topics__header">{{topic.title}}</h2>
|
||||
<span class="best-topics__excerpt">{{htmlSafe
|
||||
topic.excerpt
|
||||
}}</span>
|
||||
<span class="best-topics__excerpt">
|
||||
{{replaceEmoji (htmlSafe topic.excerpt)}}
|
||||
</span>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import avatar from "discourse/helpers/avatar";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const BotMessage = <template>
|
||||
<div class="chat-message__avatar">🤖</div>
|
||||
<div class="chat-message__bubble">
|
||||
<div class="chat-message__author">
|
||||
{{i18n "discourse_rewind.reports.chat_usage.bot_name"}}
|
||||
</div>
|
||||
{{#if @message}}
|
||||
<div class="chat-message__text">
|
||||
{{htmlSafe @message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
const UserMessage = <template>
|
||||
<div class="chat-message__bubble">
|
||||
<div class="chat-message__author">
|
||||
{{i18n "discourse_rewind.reports.chat_usage.you"}}
|
||||
</div>
|
||||
{{#if @replyKey}}
|
||||
<div class="chat-message__text">
|
||||
{{i18n (concat "discourse_rewind.reports.chat_usage." @replyKey)}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{yield}}
|
||||
</div>
|
||||
<div class="chat-message__avatar">
|
||||
{{avatar @user imageSize="small"}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default class ChatUsage extends Component {
|
||||
@service currentUser;
|
||||
|
||||
get favoriteChannels() {
|
||||
return this.args.report.data.favorite_channels ?? [];
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --chat-usage">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.chat_usage.title"
|
||||
}}</h2>
|
||||
|
||||
<div class="chat-window">
|
||||
<div class="chat-window__header">
|
||||
<span class="chat-window__title">
|
||||
{{i18n "discourse_rewind.reports.chat_usage.channel_title"}}
|
||||
</span>
|
||||
<span class="chat-window__status">
|
||||
{{i18n "discourse_rewind.reports.chat_usage.status_online"}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-window__messages">
|
||||
<div class="chat-message --left">
|
||||
<BotMessage
|
||||
@message={{i18n
|
||||
"discourse_rewind.reports.chat_usage.message_1"
|
||||
count=(number @report.data.total_messages)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chat-message --right">
|
||||
<UserMessage @user={{this.currentUser}} @replyKey="reply_1" />
|
||||
</div>
|
||||
|
||||
<div class="chat-message --left">
|
||||
<BotMessage
|
||||
@message={{htmlSafe
|
||||
(i18n
|
||||
"discourse_rewind.reports.chat_usage.message_2"
|
||||
dm_count=(number @report.data.dm_message_count)
|
||||
channel_count=(number @report.data.unique_dm_channels)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chat-message --right">
|
||||
<UserMessage @user={{this.currentUser}} @replyKey="reply_2" />
|
||||
</div>
|
||||
|
||||
<div class="chat-message --left">
|
||||
<BotMessage
|
||||
@message={{htmlSafe
|
||||
(i18n
|
||||
"discourse_rewind.reports.chat_usage.message_3"
|
||||
count=(number @report.data.total_reactions_received)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chat-message --right">
|
||||
<UserMessage @user={{this.currentUser}} @replyKey="reply_3" />
|
||||
</div>
|
||||
|
||||
<div class="chat-message --left">
|
||||
<BotMessage
|
||||
@message={{htmlSafe
|
||||
(i18n
|
||||
"discourse_rewind.reports.chat_usage.message_4"
|
||||
[email protected]_message_length
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.favoriteChannels.length}}
|
||||
<div class="chat-message --left">
|
||||
<BotMessage
|
||||
@message={{i18n
|
||||
"discourse_rewind.reports.chat_usage.message_5"
|
||||
}}
|
||||
>
|
||||
<div class="chat-message__channels">
|
||||
{{#each this.favoriteChannels as |channel|}}
|
||||
<a
|
||||
class="chat-channel-link"
|
||||
href={{concat "/chat/c/-/" channel.channel_id}}
|
||||
>
|
||||
<span
|
||||
class="chat-channel-link__name"
|
||||
>#{{channel.channel_name}}</span>
|
||||
<span class="chat-channel-link__count">
|
||||
{{number channel.message_count}}
|
||||
</span>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</BotMessage>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="chat-message --right">
|
||||
<UserMessage @user={{this.currentUser}}>
|
||||
<img
|
||||
src="/plugins/discourse-rewind/images/dancing_baby.gif"
|
||||
alt={{i18n
|
||||
"discourse_rewind.reports.chat_usage.dancing_baby_alt"
|
||||
}}
|
||||
class="chat-message__gif"
|
||||
/>
|
||||
</UserMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { concat } from "@ember/helper";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const FavoriteCategories = <template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page -favorite-categories">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.favorite_categories.title"
|
||||
[email protected]
|
||||
}}</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |data|}}
|
||||
<a href={{concat "/c/-/" data.category_id}} class="rewind-card">
|
||||
<p
|
||||
class="favorite-categories__category"
|
||||
href={{concat "/c/-/" data.category_id}}
|
||||
>{{data.name}}</p>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>;
|
||||
|
||||
export default FavoriteCategories;
|
||||
@@ -0,0 +1,53 @@
|
||||
import Component from "@glimmer/component";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class FavoriteGifs extends Component {
|
||||
get favoriteGifs() {
|
||||
return this.args.report.data.favorite_gifs ?? [];
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.favoriteGifs.length}}
|
||||
<div class="rewind-report-page --favorite-gifs">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.favorite_gifs.title"
|
||||
count=this.favoriteGifs.length
|
||||
}}</h2>
|
||||
<div class="rewind-report-subtitle">{{i18n
|
||||
"discourse_rewind.reports.favorite_gifs.total_usage"
|
||||
[email protected]_gif_usage
|
||||
}}</div>
|
||||
<div class="rewind-report-container">
|
||||
{{#each this.favoriteGifs as |gif idx|}}
|
||||
<div class="rewind-card scale">
|
||||
<div class="favorite-gifs__gif">
|
||||
<img
|
||||
src={{gif.url}}
|
||||
alt="GIF #{{idx}}"
|
||||
class="favorite-gifs__image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="favorite-gifs__stats">
|
||||
<span class="favorite-gifs__stat">
|
||||
{{icon "repeat"}}
|
||||
{{number gif.usage_count}}
|
||||
</span>
|
||||
<span class="favorite-gifs__stat">
|
||||
{{icon "heart"}}
|
||||
{{number gif.likes}}
|
||||
</span>
|
||||
<span class="favorite-gifs__stat">
|
||||
{{icon "smile"}}
|
||||
{{number gif.reactions}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { concat } from "@ember/helper";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const FavoriteTags = <template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page -favorite-tags">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.favorite_tags.title"
|
||||
[email protected]
|
||||
}}</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |data|}}
|
||||
<a class="rewind-card" href={{concat "/tag/" data.name}}>
|
||||
<p
|
||||
class="favorite-tags__tag"
|
||||
href={{concat "/tag/" data.name}}
|
||||
>#{{data.name}}</p>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>;
|
||||
|
||||
export default FavoriteTags;
|
||||
@@ -3,10 +3,10 @@ import avatar from "discourse/helpers/bound-avatar-template";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const FBFF = <template>
|
||||
<div class="rewind-report-page -fbff">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.fbff.title"
|
||||
}}</h2>
|
||||
<div class="rewind-report-page --fbff">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n "discourse_rewind.reports.fbff.title"}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
<div class="rewind-card">
|
||||
<div class="fbff-avatar-container">
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const RewindHeader = <template>
|
||||
<div class="rewind__header">
|
||||
<img
|
||||
class="rewind-logo -light"
|
||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo.png"
|
||||
/>
|
||||
<img
|
||||
class="rewind-logo -dark"
|
||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo-dark.png"
|
||||
/>
|
||||
<div class="rewind__header-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 104 106">
|
||||
<path
|
||||
fill="#231f20"
|
||||
d="M51.87 0C23.71 0 0 22.83 0 51v52.81l51.86-.05c28.16 0 51-23.71 51-51.87S80 0 51.87 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff9ae"
|
||||
d="M52.37 19.74a31.62 31.62 0 0 0-27.79 46.67l-5.72 18.4 20.54-4.64a31.61 31.61 0 1 0 13-60.43Z"
|
||||
/>
|
||||
<path
|
||||
fill="#00aeef"
|
||||
d="M77.45 32.12a31.6 31.6 0 0 1-38.05 48l-20.54 4.7 20.91-2.47a31.6 31.6 0 0 0 37.68-50.23Z"
|
||||
/>
|
||||
<path
|
||||
fill="#00a94f"
|
||||
d="M71.63 26.29A31.6 31.6 0 0 1 38.8 78l-19.94 6.82 20.54-4.65a31.6 31.6 0 0 0 32.23-53.88Z"
|
||||
/>
|
||||
<path
|
||||
fill="#f15d22"
|
||||
d="M26.47 67.11a31.61 31.61 0 0 1 51-35 31.61 31.61 0 0 0-52.89 34.3l-5.72 18.4Z"
|
||||
/>
|
||||
<path
|
||||
fill="#e31b23"
|
||||
d="M24.58 66.41a31.61 31.61 0 0 1 47.05-40.12 31.61 31.61 0 0 0-49 39.63l-3.76 18.9Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="rewind__header-title">
|
||||
{{i18n "discourse_rewind.title"}}
|
||||
</div>
|
||||
<div class="rewind__header-year">
|
||||
{{i18n "discourse_rewind.year"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user