mirror of
https://github.com/discourse/discourse.git
synced 2026-07-31 16:48:26 -05:00
FEATURE: Bundle discourse-rewind plugin in core (#36409)
We think this is in a good place to move into core and prepare for a wider release after the additional work that has been done this year. --------- Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com> Co-authored-by: Kris Aubuchon <kris.aubuchon@discourse.org> Co-authored-by: Rafael dos Santos Silva <xfalcox@gmail.com>
This commit is contained in:
co-authored by
Joffrey JAFFEUX
Kris Aubuchon
Rafael dos Santos Silva
parent
75a7b06ebe
commit
95bb1bdf30
@@ -72,6 +72,7 @@
|
||||
!/plugins/discourse-templates
|
||||
!/plugins/discourse-ai
|
||||
!/plugins/discourse-cakeday
|
||||
!/plugins/discourse-rewind
|
||||
/plugins/*/auto_generated
|
||||
|
||||
/spec/fixtures/plugins/my_plugin/auto_generated
|
||||
|
||||
@@ -84,6 +84,7 @@ class Plugin::Metadata
|
||||
discourse-yearly-review
|
||||
discourse-zendesk-plugin
|
||||
discourse-zoom
|
||||
discourse-rewind
|
||||
automation
|
||||
chat
|
||||
checklist
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# DiscourseRewind
|
||||
|
||||
Display stats on your last year of Discourse usage.
|
||||
|
||||
## Usage
|
||||
|
||||
Navigate to `/my/activity/rewind`
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::DiscourseRewind
|
||||
class RewindsController < ::ApplicationController
|
||||
requires_plugin PLUGIN_NAME
|
||||
|
||||
requires_login
|
||||
|
||||
def show
|
||||
DiscourseRewind::FetchReports.call(service_params) do
|
||||
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
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# For a GitHub like calendar
|
||||
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class ActivityCalendar < BaseReport
|
||||
FakeData = {
|
||||
data:
|
||||
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
|
||||
{ date:, post_count: rand(0..20), visited: false }
|
||||
end,
|
||||
identifier: "activity-calendar",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
|
||||
calendar =
|
||||
Post
|
||||
.unscoped
|
||||
.joins(<<~SQL)
|
||||
RIGHT JOIN
|
||||
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
|
||||
posts.created_at::date = generate_series::date AND
|
||||
posts.user_id = #{user.id} AND
|
||||
posts.deleted_at IS NULL
|
||||
SQL
|
||||
.joins(
|
||||
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
|
||||
)
|
||||
.select(
|
||||
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
|
||||
)
|
||||
.group("generate_series, user_visits.id")
|
||||
.order("generate_series")
|
||||
.map { |row| { date: row.date, post_count: row.post_count, visited: row.visited } }
|
||||
|
||||
{ data: calendar, identifier: "activity-calendar" }
|
||||
end
|
||||
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 should_use_fake_data?
|
||||
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?
|
||||
Discourse.plugins_by_name["discourse-ai"]&.enabled?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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 should_use_fake_data?
|
||||
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?
|
||||
Discourse.plugins_by_name["discourse-assign"]&.enabled?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class BaseReport < Service::ActionBase
|
||||
option :user
|
||||
option :date
|
||||
|
||||
def call
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def self.enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def should_use_fake_data?
|
||||
Rails.env.development?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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 should_use_fake_data?
|
||||
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, created_at ASC")
|
||||
.limit(3)
|
||||
.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
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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 should_use_fake_data?
|
||||
best_topics =
|
||||
TopTopic
|
||||
.includes(:topic)
|
||||
.references(:topic)
|
||||
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
|
||||
.order("yearly_score DESC NULLS LAST")
|
||||
.limit(3)
|
||||
.pluck(:topic_id, :title, :excerpt, :yearly_score)
|
||||
.map do |topic_id, title, excerpt, yearly_score|
|
||||
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
|
||||
end
|
||||
|
||||
{ data: best_topics, identifier: "best-topics" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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 should_use_fake_data?
|
||||
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?
|
||||
Discourse.plugins_by_name["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 should_use_fake_data?
|
||||
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 Discourse.plugins_by_name["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
|
||||
@@ -0,0 +1,140 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Forum Best Friend Forever ranking
|
||||
# Score is informative only, do not show in UI
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class Fbff < BaseReport
|
||||
MAX_SUMMARY_RESULTS = 50
|
||||
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 should_use_fake_data?
|
||||
|
||||
most_liked_users =
|
||||
like_query(date)
|
||||
.where(acting_user_id: user.id)
|
||||
.group(:user_id)
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(MAX_SUMMARY_RESULTS)
|
||||
.pluck("user_actions.user_id, COUNT(*)")
|
||||
.map { |user_id, count| { user_id => count } }
|
||||
.reduce({}, :merge)
|
||||
|
||||
most_liked_by_users =
|
||||
like_query(date)
|
||||
.where(user: user)
|
||||
.group(:acting_user_id)
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(MAX_SUMMARY_RESULTS)
|
||||
.pluck("acting_user_id, COUNT(*)")
|
||||
.map { |acting_user_id, count| { acting_user_id => count } }
|
||||
.reduce({}, :merge)
|
||||
|
||||
users_who_most_replied_me =
|
||||
post_query(user, date)
|
||||
.where(posts: { user_id: user.id })
|
||||
.group("replies.user_id")
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(MAX_SUMMARY_RESULTS)
|
||||
.pluck("replies.user_id, COUNT(*)")
|
||||
.map { |user_id, count| { user_id => count } }
|
||||
.reduce({}, :merge)
|
||||
|
||||
users_i_most_replied =
|
||||
post_query(user, date)
|
||||
.where("replies.user_id = ?", user.id)
|
||||
.group("posts.user_id")
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(MAX_SUMMARY_RESULTS)
|
||||
.pluck("posts.user_id, COUNT(*)")
|
||||
.map { |user_id, count| { user_id => count } }
|
||||
.reduce({}, :merge)
|
||||
|
||||
# NOTE: At some point maybe we want to include chat interactions
|
||||
# in the calculations here.
|
||||
fbffs = [
|
||||
apply_score(most_liked_users, LIKE_SCORE),
|
||||
apply_score(most_liked_by_users, LIKE_SCORE),
|
||||
apply_score(users_who_most_replied_me, REPLY_SCORE),
|
||||
apply_score(users_i_most_replied, REPLY_SCORE),
|
||||
]
|
||||
|
||||
fbff_id =
|
||||
fbffs
|
||||
.flatten
|
||||
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
|
||||
&.sort_by { |_, v| -v }
|
||||
&.first
|
||||
&.first
|
||||
|
||||
return if !fbff_id
|
||||
|
||||
{
|
||||
data: {
|
||||
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
|
||||
yourself: BasicUserSerializer.new(user, root: false).as_json,
|
||||
},
|
||||
identifier: "fbff",
|
||||
}
|
||||
end
|
||||
|
||||
def post_query(user, date)
|
||||
Post
|
||||
.with(eligible_users: User.real.activated.not_suspended.select(:id))
|
||||
.joins(:topic)
|
||||
.includes(:topic)
|
||||
.where(
|
||||
"posts.post_type IN (?)",
|
||||
Topic.visible_post_types(user, include_moderator_actions: false),
|
||||
)
|
||||
.joins(
|
||||
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
|
||||
)
|
||||
.joins(
|
||||
"INNER JOIN topics ON replies.topic_id = topics.id
|
||||
AND topics.archetype <> 'private_message'
|
||||
AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
|
||||
)
|
||||
.joins("INNER JOIN eligible_users eu ON eu.id = replies.user_id")
|
||||
.joins("INNER JOIN eligible_users eu2 ON eu2.id = posts.user_id")
|
||||
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
|
||||
.where("posts.created_at BETWEEN ? AND ?", date.first, date.last)
|
||||
.where("replies.user_id <> posts.user_id")
|
||||
end
|
||||
|
||||
def like_query(date)
|
||||
UserAction
|
||||
.with(eligible_users: User.real.activated.not_suspended.select(:id))
|
||||
.joins(:target_topic, :target_post)
|
||||
.joins("INNER JOIN eligible_users eu ON eu.id = user_actions.user_id")
|
||||
.joins("INNER JOIN eligible_users eu2 ON eu2.id = user_actions.acting_user_id")
|
||||
.where(created_at: date)
|
||||
.where(action_type: UserAction::WAS_LIKED)
|
||||
end
|
||||
|
||||
def apply_score(users, score)
|
||||
users.map { |user_id, count| { user_id => count * score } }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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 should_use_fake_data?
|
||||
# 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
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Topics visited grouped by category
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
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" },
|
||||
],
|
||||
identifier: "most-viewed-categories",
|
||||
}
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
|
||||
most_viewed_categories =
|
||||
TopicViewItem
|
||||
.joins(:topic)
|
||||
.joins("INNER JOIN categories ON categories.id = topics.category_id")
|
||||
.where(
|
||||
user: user,
|
||||
viewed_at: date,
|
||||
categories: {
|
||||
id: user.guardian.allowed_category_ids,
|
||||
},
|
||||
)
|
||||
.group("categories.id, categories.name")
|
||||
.order("COUNT(*) DESC")
|
||||
.limit(4)
|
||||
.pluck("categories.id, categories.name")
|
||||
.map { |category_id, name| { category_id: category_id, name: name } }
|
||||
|
||||
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Topics visited grouped by tag
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
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" },
|
||||
],
|
||||
identifier: "most-viewed-tags",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
|
||||
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, 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(4)
|
||||
.pluck("tags.id, tags.name")
|
||||
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
||||
|
||||
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+78
@@ -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 should_use_fake_data?
|
||||
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
|
||||
@@ -0,0 +1,93 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# For a most user / received reactions cards
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class Reactions < BaseReport
|
||||
FakeData = {
|
||||
data: {
|
||||
post_received_reactions: {
|
||||
"open_mouth" => 2,
|
||||
"cat" => 32,
|
||||
"dog" => 34,
|
||||
"heart" => 45,
|
||||
"grinning" => 82,
|
||||
},
|
||||
post_used_reactions: {
|
||||
"open_mouth" => 2,
|
||||
"cat" => 32,
|
||||
"dog" => 34,
|
||||
"heart" => 45,
|
||||
"grinning" => 82,
|
||||
},
|
||||
chat_used_reactions: {
|
||||
"open_mouth" => 2,
|
||||
"cat" => 32,
|
||||
"dog" => 34,
|
||||
"heart" => 45,
|
||||
"grinning" => 82,
|
||||
},
|
||||
chat_received_reactions: {
|
||||
"open_mouth" => 2,
|
||||
"cat" => 32,
|
||||
"dog" => 34,
|
||||
"heart" => 45,
|
||||
"grinning" => 82,
|
||||
},
|
||||
},
|
||||
identifier: "reactions",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
return if !enabled?
|
||||
|
||||
data = {}
|
||||
if defined?(DiscourseReactions::Reaction)
|
||||
# This is missing heart reactions (default like)
|
||||
data[:post_used_reactions] = sort_and_limit(
|
||||
DiscourseReactions::Reaction
|
||||
.by_user(user)
|
||||
.where(created_at: date)
|
||||
.group(:reaction_value)
|
||||
.count,
|
||||
)
|
||||
|
||||
data[:post_received_reactions] = sort_and_limit(
|
||||
DiscourseReactions::Reaction
|
||||
.includes(:post)
|
||||
.where(posts: { user_id: user.id })
|
||||
.where(created_at: date)
|
||||
.group(:reaction_value)
|
||||
.count,
|
||||
)
|
||||
end
|
||||
|
||||
if Discourse.plugins_by_name["chat"]&.enabled?
|
||||
data[:chat_used_reactions] = sort_and_limit(
|
||||
Chat::MessageReaction.where(user: user).where(created_at: date).group(:emoji).count,
|
||||
)
|
||||
|
||||
data[:chat_received_reactions] = sort_and_limit(
|
||||
Chat::MessageReaction
|
||||
.includes(:chat_message)
|
||||
.where(chat_message: { user_id: user.id })
|
||||
.where(created_at: date)
|
||||
.group(:emoji)
|
||||
.count,
|
||||
)
|
||||
end
|
||||
|
||||
{ data:, identifier: "reactions" }
|
||||
end
|
||||
|
||||
def enabled?
|
||||
Discourse.plugins_by_name["discourse-reactions"]&.enabled?
|
||||
end
|
||||
|
||||
def sort_and_limit(reactions)
|
||||
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# For showcasing the reading time of a user
|
||||
# Should we show book covers or just the names?
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class ReadingTime < BaseReport
|
||||
POPULAR_BOOKS = {
|
||||
"The Metamorphosis" => {
|
||||
reading_time: 3120,
|
||||
isbn: "978-0553213690",
|
||||
series: false,
|
||||
},
|
||||
"The Little Prince" => {
|
||||
reading_time: 5400,
|
||||
isbn: "978-0156012195",
|
||||
series: false,
|
||||
},
|
||||
"Animal Farm" => {
|
||||
reading_time: 7200,
|
||||
isbn: "978-0451526342",
|
||||
series: false,
|
||||
},
|
||||
"The Alchemist" => {
|
||||
reading_time: 10_800,
|
||||
isbn: "978-0061122415",
|
||||
series: false,
|
||||
},
|
||||
"The Great Gatsby" => {
|
||||
reading_time: 12_600,
|
||||
isbn: "978-0743273565",
|
||||
series: false,
|
||||
},
|
||||
"The Hitchhiker's Guide to the Galaxy" => {
|
||||
reading_time: 12_600,
|
||||
isbn: "978-0345391803",
|
||||
series: false,
|
||||
},
|
||||
"Fahrenheit 451" => {
|
||||
reading_time: 15_000,
|
||||
isbn: "978-1451673319",
|
||||
series: false,
|
||||
},
|
||||
"And Then There Were None" => {
|
||||
reading_time: 16_200,
|
||||
isbn: "978-0062073488",
|
||||
series: false,
|
||||
},
|
||||
"1984" => {
|
||||
reading_time: 16_800,
|
||||
isbn: "978-0451524935",
|
||||
series: false,
|
||||
},
|
||||
"The Catcher in the Rye" => {
|
||||
reading_time: 18_000,
|
||||
isbn: "978-0316769488",
|
||||
series: false,
|
||||
},
|
||||
"The Hunger Games" => {
|
||||
reading_time: 19_740,
|
||||
isbn: "978-0439023481",
|
||||
series: false,
|
||||
},
|
||||
"To Kill a Mockingbird" => {
|
||||
reading_time: 22_800,
|
||||
isbn: "978-0061120084",
|
||||
series: false,
|
||||
},
|
||||
"A Tale of Two Cities" => {
|
||||
reading_time: 24_600,
|
||||
isbn: "978-0141439600",
|
||||
series: false,
|
||||
},
|
||||
"Pride and Prejudice" => {
|
||||
reading_time: 25_200,
|
||||
isbn: "978-1503290563",
|
||||
series: false,
|
||||
},
|
||||
"The Hobbit" => {
|
||||
reading_time: 27_000,
|
||||
isbn: "978-0547928227",
|
||||
series: false,
|
||||
},
|
||||
"Little Women" => {
|
||||
reading_time: 30_000,
|
||||
isbn: "978-0147514011",
|
||||
series: false,
|
||||
},
|
||||
"Jane Eyre" => {
|
||||
reading_time: 34_200,
|
||||
isbn: "978-0141441146",
|
||||
series: false,
|
||||
},
|
||||
"The Da Vinci Code" => {
|
||||
reading_time: 37_800,
|
||||
isbn: "978-0307474278",
|
||||
series: false,
|
||||
},
|
||||
"One Hundred Years of Solitude" => {
|
||||
reading_time: 46_800,
|
||||
isbn: "978-0060883287",
|
||||
series: false,
|
||||
},
|
||||
"The Lord of the Rings" => {
|
||||
reading_time: 108_000,
|
||||
isbn: "978-0544003415",
|
||||
series: true,
|
||||
},
|
||||
"The Complete works of Shakespeare" => {
|
||||
reading_time: 180_000,
|
||||
isbn: "978-1853268953",
|
||||
series: true,
|
||||
},
|
||||
"The Game of Thrones Series" => {
|
||||
reading_time: 360_000,
|
||||
isbn: "978-0007477159",
|
||||
series: true,
|
||||
},
|
||||
"Malazan Book of the Fallen" => {
|
||||
reading_time: 720_000,
|
||||
isbn: "978-0765348821",
|
||||
series: true,
|
||||
},
|
||||
"Terry Pratchett's Discworld series" => {
|
||||
reading_time: 1_440_000,
|
||||
isbn: "978-9123684458",
|
||||
series: true,
|
||||
},
|
||||
"The Wandering Inn web series" => {
|
||||
reading_time: 2_160_000,
|
||||
isbn: "the-wandering-inn",
|
||||
series: true,
|
||||
},
|
||||
"The Combined Cosmere works + Wheel of Time" => {
|
||||
reading_time: 2_880_000,
|
||||
isbn: "978-0812511819",
|
||||
series: true,
|
||||
},
|
||||
"The Star Trek novels" => {
|
||||
reading_time: 3_600_000,
|
||||
isbn: "978-1852860691",
|
||||
series: true,
|
||||
},
|
||||
}.symbolize_keys
|
||||
|
||||
FakeData = {
|
||||
data: {
|
||||
reading_time: 2_880_000,
|
||||
book: "The Combined Cosmere works + Wheel of Time",
|
||||
isbn: "978-0812511819",
|
||||
series: true,
|
||||
},
|
||||
identifier: "reading-time",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
|
||||
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
|
||||
book = best_book_fit(reading_time)
|
||||
|
||||
return if book.nil?
|
||||
|
||||
{
|
||||
data: {
|
||||
reading_time: reading_time,
|
||||
book: book[:title].to_s,
|
||||
isbn: book[:isbn],
|
||||
series: book[:series],
|
||||
},
|
||||
identifier: "reading-time",
|
||||
}
|
||||
end
|
||||
|
||||
def best_book_fit(reading_time)
|
||||
best_fit =
|
||||
POPULAR_BOOKS
|
||||
.select { |_, v| v[:reading_time] > reading_time }
|
||||
.min_by { |_, v| v[:reading_time] }
|
||||
|
||||
return if best_fit.nil?
|
||||
|
||||
{ title: best_fit.first, isbn: best_fit.last[:isbn], series: best_fit.last[:series] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+145
@@ -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 should_use_fake_data?
|
||||
# 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 Discourse.plugins_by_name["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
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseRewind
|
||||
module Action
|
||||
class TopWords < BaseReport
|
||||
FakeData = {
|
||||
data: [
|
||||
{ word: "seven", score: 100 },
|
||||
{ word: "longest", score: 90 },
|
||||
{ word: "you", score: 80 },
|
||||
{ word: "overachieved", score: 70 },
|
||||
{ word: "assume", score: 60 },
|
||||
],
|
||||
identifier: "top-words",
|
||||
}
|
||||
|
||||
def call
|
||||
return FakeData if should_use_fake_data?
|
||||
|
||||
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||
WITH popular_words AS (
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
ts_stat(
|
||||
$INNERSQL$
|
||||
SELECT
|
||||
search_data
|
||||
FROM
|
||||
post_search_data
|
||||
INNER JOIN
|
||||
posts ON posts.id = post_search_data.post_id
|
||||
WHERE
|
||||
posts.user_id = :user_id
|
||||
AND posts.created_at BETWEEN :date_start AND :date_end
|
||||
$INNERSQL$
|
||||
) AS search_data
|
||||
WHERE LENGTH(word) >= 2
|
||||
AND word ~ '^[a-zA-Z]+$'
|
||||
AND word NOT IN (
|
||||
'com', 'org', 'net', 'io', 'dev', 'co', 'uk', 'http', 'https',
|
||||
'www', 'github', 'gitlab', 'google', 'youtube', 'twitter',
|
||||
'slack', 'discord', 'drive'
|
||||
)
|
||||
ORDER BY
|
||||
nentry DESC,
|
||||
ndoc DESC,
|
||||
word
|
||||
LIMIT
|
||||
100
|
||||
), lex AS (
|
||||
SELECT
|
||||
DISTINCT ON (lexeme) to_tsvector('english', word) as lexeme,
|
||||
word as original_word
|
||||
FROM
|
||||
ts_stat ($INNERSQL$
|
||||
SELECT
|
||||
to_tsvector('simple',
|
||||
regexp_replace(raw, 'https?://[^\\s]+', ' ', 'g')
|
||||
)
|
||||
FROM
|
||||
posts AS p
|
||||
WHERE
|
||||
p.user_id = :user_id
|
||||
AND p.created_at BETWEEN :date_start AND :date_end
|
||||
$INNERSQL$)
|
||||
WHERE LENGTH(word) >= 2
|
||||
AND word ~ '^[a-zA-Z]+$'
|
||||
), ranked_words AS (
|
||||
SELECT
|
||||
popular_words.*, lex.original_word,
|
||||
ROW_NUMBER() OVER (PARTITION BY word ORDER BY LENGTH(original_word) DESC) AS rn
|
||||
FROM
|
||||
popular_words
|
||||
INNER JOIN
|
||||
lex ON lex.lexeme @@ to_tsquery('english', popular_words.word)
|
||||
)
|
||||
SELECT
|
||||
word,
|
||||
ndoc,
|
||||
nentry,
|
||||
original_word
|
||||
FROM
|
||||
ranked_words
|
||||
WHERE
|
||||
rn = 1
|
||||
ORDER BY
|
||||
ndoc + nentry DESC
|
||||
LIMIT 10
|
||||
SQL
|
||||
|
||||
word_score =
|
||||
words
|
||||
.map do |word_data|
|
||||
# Little cheat, since sometimes the stemming process turns
|
||||
# "discourse" into "discour" or "discours"
|
||||
if word_data.original_word == "discour" || word_data.original_word == "discours"
|
||||
word_data.original_word = "discourse"
|
||||
end
|
||||
|
||||
{ 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
|
||||
end
|
||||
end
|
||||
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 should_use_fake_data?
|
||||
|
||||
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
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseRewind
|
||||
# Service responsible to fetch a rewind for a username/year.
|
||||
#
|
||||
# @example
|
||||
# ::DiscourseRewind::Rewind::Fetch.call(
|
||||
# guardian: guardian,
|
||||
# params: { year: 2023, username: 'codinghorror' }
|
||||
# )
|
||||
#
|
||||
class FetchReports
|
||||
include Service::Base
|
||||
|
||||
# @!method self.call(guardian:, params:)
|
||||
# @param [Guardian] guardian
|
||||
# @param [Hash] params
|
||||
# @option params [Integer] :year of the rewind
|
||||
# @option params [Integer] :username of the rewind
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
|
||||
|
||||
# The order here controls the order of reports in the UI,
|
||||
# so be careful when moving these around.
|
||||
REPORTS = [
|
||||
Action::TopWords,
|
||||
Action::ReadingTime,
|
||||
Action::WritingAnalysis,
|
||||
Action::Reactions,
|
||||
Action::Fbff,
|
||||
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 :date
|
||||
model :reports
|
||||
|
||||
private
|
||||
|
||||
def fetch_year
|
||||
current_date = Time.zone.now
|
||||
current_month = current_date.month
|
||||
current_year = current_date.year
|
||||
|
||||
case current_month
|
||||
when 1
|
||||
current_year - 1
|
||||
when 12
|
||||
current_year
|
||||
else
|
||||
# Otherwise it's impossible to test in browser locally unless you're
|
||||
# in December or January
|
||||
if Rails.env.development?
|
||||
current_year
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_date(params:, year:)
|
||||
Date.new(year).all_year
|
||||
end
|
||||
|
||||
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.user, guardian:) }.compact
|
||||
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
|
||||
else
|
||||
reports = MultiJson.load(reports, symbolize_keys: true)
|
||||
end
|
||||
|
||||
reports
|
||||
end
|
||||
end
|
||||
end
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const ROWS = 7;
|
||||
const COLS = 53;
|
||||
|
||||
export default class ActivityCalendar extends Component {
|
||||
get rowsArray() {
|
||||
const data = this.args.report.data;
|
||||
let rowsArray = [];
|
||||
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
let rowData = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const index = c * ROWS + r;
|
||||
rowData.push(data[index] ? data[index] : "");
|
||||
}
|
||||
rowsArray.push(rowData);
|
||||
}
|
||||
|
||||
return rowsArray;
|
||||
}
|
||||
|
||||
@action
|
||||
getAbbreviatedMonth(monthIndex) {
|
||||
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";
|
||||
} else if (count < 10) {
|
||||
return "--low";
|
||||
} else if (count < 20) {
|
||||
return "--medium";
|
||||
} else {
|
||||
return "--high";
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --activity-calendar">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.activity_calendar.title"
|
||||
}}</h2>
|
||||
|
||||
<div class="rewind-card">
|
||||
<table class="rewind-calendar">
|
||||
<thead>
|
||||
<tr>
|
||||
<td
|
||||
colspan="5"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 0}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 1}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 2}}</td>
|
||||
<td
|
||||
colspan="5"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 3}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 4}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 5}}</td>
|
||||
<td
|
||||
colspan="5"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 6}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 7}}</td>
|
||||
<td
|
||||
colspan="5"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 8}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 9}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 10}}</td>
|
||||
<td
|
||||
colspan="4"
|
||||
class="activity-header-cell"
|
||||
>{{this.getAbbreviatedMonth 11}}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each this.rowsArray as |row|}}
|
||||
<tr>
|
||||
{{#each row as |cell|}}
|
||||
<td
|
||||
data-date={{cell.date}}
|
||||
title={{this.computeCellTitle cell}}
|
||||
class={{concatClass
|
||||
"rewind-calendar-cell"
|
||||
(this.computeClass cell.post_count)
|
||||
}}
|
||||
></td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
}
|
||||
|
||||
get minimumDataThresholdMet() {
|
||||
return this.totalRequests >= 10 && this.totalTokens >= 1000;
|
||||
}
|
||||
|
||||
@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>
|
||||
{{#if this.minimumDataThresholdMet}}
|
||||
<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>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import Component from "@glimmer/component";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class Assignments extends Component {
|
||||
get minimumDataThresholdMet() {
|
||||
return (
|
||||
(this.args.report.data.completed > 0 ||
|
||||
this.args.report.data.pending > 0) &&
|
||||
this.args.report.data.total_assigned > 0
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.minimumDataThresholdMet}}
|
||||
<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>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class BestPosts extends Component {
|
||||
rankClass(idx) {
|
||||
return `rank-${idx + 1}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page --best-posts">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.best_posts.title"
|
||||
count=@report.data.length
|
||||
}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |post idx|}}
|
||||
<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"}}{{post.like_count}}
|
||||
</span>
|
||||
<span class="best-posts__replies">
|
||||
{{icon "comment"}}{{post.reply_count}}
|
||||
</span>
|
||||
<a
|
||||
href={{getURL
|
||||
(concat "/t/" post.topic_id "/" post.post_number)
|
||||
}}
|
||||
>
|
||||
{{i18n "discourse_rewind.reports.best_posts.view_post"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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 {
|
||||
rankClass(idx) {
|
||||
return `rank-${idx + 1}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page --best-topics">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.best_topics.title"
|
||||
count=@report.data.length
|
||||
}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
<div class="rewind-card">
|
||||
{{#each @report.data as |topic idx|}}
|
||||
<div
|
||||
class={{concatClass "best-topics__topic" (this.rankClass idx)}}
|
||||
>
|
||||
<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">
|
||||
{{replaceEmoji (htmlSafe topic.excerpt)}}
|
||||
</span>
|
||||
|
||||
<div class="best-topics__metadata">
|
||||
<a href={{getURL (concat "/t/-/" topic.topic_id)}}>
|
||||
{{i18n "discourse_rewind.reports.best_topics.view_topic"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
get minimumDataThresholdMet() {
|
||||
return (
|
||||
this.args.report.data.total_messages >= 20 &&
|
||||
this.args.report.data.unique_dm_channels >= 2 &&
|
||||
this.args.report.data.favorite_channels.length >= 1
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.minimumDataThresholdMet}}
|
||||
<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"
|
||||
length=@report.data.avg_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>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+53
@@ -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"
|
||||
count=@report.data.total_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>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { hash } from "@ember/helper";
|
||||
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-container">
|
||||
<div class="rewind-card">
|
||||
<div class="fbff-avatar-container">
|
||||
{{avatar
|
||||
@report.data.fbff.avatar_template
|
||||
"huge"
|
||||
(hash title=@report.data.fbff.username)
|
||||
}}
|
||||
<p class="fbff-avatar-name">@{{@report.data.fbff.username}}</p>
|
||||
</div>
|
||||
<div class="fbff-gif-container">
|
||||
<img
|
||||
class="fbff-gif"
|
||||
src="/plugins/discourse-rewind/images/fbff.gif"
|
||||
/>
|
||||
</div>
|
||||
<div class="fbff-avatar-container">
|
||||
{{avatar
|
||||
@report.data.yourself.avatar_template
|
||||
"huge"
|
||||
(hash title=@report.data.yourself.username)
|
||||
}}
|
||||
<p class="fbff-avatar-name">@{{@report.data.yourself.username}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default FBFF;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { fetchRewindYear } from "../../connectors/before-panel-body/rewind-callout";
|
||||
|
||||
const RewindHeader = <template>
|
||||
<div class="rewind__header">
|
||||
<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">
|
||||
{{fetchRewindYear}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default RewindHeader;
|
||||
@@ -0,0 +1,132 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import avatar from "discourse/helpers/bound-avatar-template";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class Invites extends Component {
|
||||
get mostActiveInvitee() {
|
||||
return this.args.report.data.most_active_invitee;
|
||||
}
|
||||
|
||||
get minimumDataThresholdMet() {
|
||||
return (
|
||||
this.args.report.data.total_invites >= 2 &&
|
||||
this.args.report.data.redeemed_count >= 1 &&
|
||||
this.args.report.data.invitee_post_count >= 5
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.minimumDataThresholdMet}}
|
||||
<div class="rewind-report-page --invites">
|
||||
<div class="guest-book">
|
||||
<div class="guest-book__cover">
|
||||
<div class="guest-book__title">
|
||||
{{i18n "discourse_rewind.reports.invites.guest_book_title"}}
|
||||
</div>
|
||||
<div class="guest-book__subtitle">
|
||||
{{i18n "discourse_rewind.reports.invites.guest_book_subtitle"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__page">
|
||||
<div class="guest-book__entry">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.invites.label_invitations_sent"
|
||||
}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{number @report.data.total_invites}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__entry">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n "discourse_rewind.reports.invites.label_guests_joined"}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{number @report.data.redeemed_count}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__entry">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.invites.label_acceptance_rate"
|
||||
}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{@report.data.redemption_rate}}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__divider"></div>
|
||||
|
||||
<div class="guest-book__section-title">
|
||||
{{i18n "discourse_rewind.reports.invites.section_contributions"}}
|
||||
</div>
|
||||
|
||||
<div class="guest-book__entry --small">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n "discourse_rewind.reports.invites.label_posts_written"}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{number @report.data.invitee_post_count}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__entry --small">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n "discourse_rewind.reports.invites.label_topics_started"}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{number @report.data.invitee_topic_count}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guest-book__entry --small">
|
||||
<div class="guest-book__entry-label">
|
||||
{{i18n "discourse_rewind.reports.invites.label_likes_given"}}
|
||||
</div>
|
||||
<div class="guest-book__entry-value">
|
||||
{{number @report.data.invitee_like_count}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.mostActiveInvitee}}
|
||||
<div class="guest-book__divider"></div>
|
||||
|
||||
<div class="guest-book__section-title">
|
||||
{{i18n "discourse_rewind.reports.invites.section_most_active"}}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={{concat "/u/" this.mostActiveInvitee.username}}
|
||||
class="guest-book__signature"
|
||||
>
|
||||
{{avatar
|
||||
this.mostActiveInvitee.avatar_template
|
||||
"large"
|
||||
username=this.mostActiveInvitee.username
|
||||
name=this.mostActiveInvitee.name
|
||||
}}
|
||||
<div class="guest-book__signature-info">
|
||||
<span class="guest-book__signature-name">
|
||||
{{this.mostActiveInvitee.username}}
|
||||
</span>
|
||||
{{#if this.mostActiveInvitee.name}}
|
||||
<span class="guest-book__signature-realname">
|
||||
{{this.mostActiveInvitee.name}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { eq } from "discourse/truth-helpers";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
/**
|
||||
* Component displaying most viewed categories in rewind report
|
||||
* @component
|
||||
* @param {Object} report - Report data containing category view counts
|
||||
*/
|
||||
export default class MostViewedCategories extends Component {
|
||||
@tracked openedCategoryId = null;
|
||||
|
||||
/**
|
||||
* Handles click on folder-wrapper, we want to open
|
||||
* the folder on click then navigate to it on second click.
|
||||
* @action
|
||||
*/
|
||||
@action
|
||||
handleFolderClick(categoryId, event) {
|
||||
if (this.openedCategoryId === categoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openedCategoryId = categoryId;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page --most-viewed-categories">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.most_viewed_categories.title"
|
||||
count=@report.data.length
|
||||
}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |data|}}
|
||||
<a
|
||||
class={{concatClass
|
||||
"folder-wrapper"
|
||||
(if (eq this.openedCategoryId data.category_id) "--opened" "")
|
||||
}}
|
||||
href={{concat "/c/-/" data.category_id}}
|
||||
{{on "click" (fn this.handleFolderClick data.category_id)}}
|
||||
>
|
||||
<span class="folder-tab"></span>
|
||||
<div class="rewind-card">
|
||||
<p class="most-viewed-categories__category">#{{data.name}}</p>
|
||||
</div>
|
||||
<span class="folder-bg"></span>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { eq } from "discourse/truth-helpers";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class MostViewedTags extends Component {
|
||||
@tracked openedTag = null;
|
||||
|
||||
/**
|
||||
* Handles click on folder-wrapper, we want to open
|
||||
* the folder on click then navigate to it on second click.
|
||||
* @action
|
||||
*/
|
||||
@action
|
||||
handleFolderClick(tag, event) {
|
||||
if (this.openedTag === tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openedTag = tag;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data.length}}
|
||||
<div class="rewind-report-page --most-viewed-tags">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.most_viewed_tags.title"
|
||||
count=@report.data.length
|
||||
}}</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each @report.data as |data|}}
|
||||
<a
|
||||
class={{concatClass
|
||||
"folder-wrapper"
|
||||
(if (eq this.openedTag data.name) "--opened" "")
|
||||
}}
|
||||
href={{concat "/tag/" data.name}}
|
||||
{{on "click" (fn this.handleFolderClick data.name)}}
|
||||
>
|
||||
<span class="folder-tab"></span>
|
||||
<div class="rewind-card">
|
||||
<p
|
||||
class="most-viewed-tags__tag"
|
||||
href={{concat "/tag/" data.name}}
|
||||
>
|
||||
#{{data.name}}
|
||||
</p>
|
||||
</div>
|
||||
<span class="folder-bg"></span>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class NewUserInteractions extends Component {
|
||||
get wavyWords() {
|
||||
const num = this.args.report.data.unique_new_users;
|
||||
const memberText = i18n(
|
||||
"discourse_rewind.reports.new_user_interactions.new_member",
|
||||
{ count: num }
|
||||
);
|
||||
return memberText.split(" ").map((word) => word.split(""));
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --new-user-interactions">
|
||||
<div class="wordart-container">
|
||||
<div class="wordart-text">your contributions helped</div>
|
||||
<div class="wordart-3d">
|
||||
{{#each this.wavyWords as |word|}}
|
||||
<span class="wordart-word">
|
||||
{{#each word as |char|}}
|
||||
<span class="wordart-letter">{{char}}</span>
|
||||
{{/each}}
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class Reactions extends Component {
|
||||
get totalPostUsedReactions() {
|
||||
return Object.values(
|
||||
this.args.report.data.post_used_reactions ?? {}
|
||||
).reduce((acc, count) => acc + count, 0);
|
||||
}
|
||||
|
||||
get receivedReactions() {
|
||||
return this.args.report.data.post_received_reactions ?? {};
|
||||
}
|
||||
|
||||
@action
|
||||
cleanEmoji(emojiName) {
|
||||
return emojiName.replaceAll(/_/g, " ");
|
||||
}
|
||||
|
||||
@action
|
||||
computePercentage(count) {
|
||||
return `${((count / this.totalPostUsedReactions) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
@action
|
||||
computePercentageStyle(count) {
|
||||
return htmlSafe(`width: ${this.computePercentage(count)}`);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --post-received-reactions">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n "discourse_rewind.reports.post_received_reactions.title"}}
|
||||
</h2>
|
||||
<div class="rewind-report-container">
|
||||
{{#each-in this.receivedReactions as |emojiName count|}}
|
||||
<div class="rewind-card scale">
|
||||
<span class="rewind-card__emoji">
|
||||
{{replaceEmoji (concat ":" emojiName ":")}}
|
||||
</span>
|
||||
<span class="rewind-card__data">{{count}}</span>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rewind-report-page --post-used-reactions">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n "discourse_rewind.reports.post_used_reactions.title"}}
|
||||
</h2>
|
||||
<div class="rewind-card">
|
||||
<div class="rewind-reactions-chart">
|
||||
{{#each-in @report.data.post_used_reactions as |emojiName count|}}
|
||||
<div class="rewind-reactions-row">
|
||||
<span class="emoji">
|
||||
{{replaceEmoji (concat ":" emojiName ":")}}
|
||||
</span>
|
||||
<span class="percentage">{{this.computePercentage count}}</span>
|
||||
<div
|
||||
class="rewind-reactions-bar"
|
||||
style={{this.computePercentageStyle count}}
|
||||
title={{count}}
|
||||
></div>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
|
||||
<span class="rewind-total-reactions">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.post_used_reactions.total_number"
|
||||
count=this.totalPostUsedReactions
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class ReadingTime extends Component {
|
||||
get readTimeString() {
|
||||
let totalMinutes = Math.floor(this.args.report.data.reading_time / 60);
|
||||
let leftOverMinutes = totalMinutes % 60;
|
||||
let totalHours = (totalMinutes - leftOverMinutes) / 60;
|
||||
|
||||
if (leftOverMinutes >= 35) {
|
||||
totalHours += 1;
|
||||
leftOverMinutes = 0;
|
||||
return `${totalHours}h`;
|
||||
} else {
|
||||
return `${totalHours}h ${leftOverMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @report.data}}
|
||||
<div class="rewind-report-page --reading-time">
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n "discourse_rewind.reports.reading_time.title"}}
|
||||
</h2>
|
||||
<div class="rewind-card">
|
||||
<p class="reading-time__text">
|
||||
{{htmlSafe
|
||||
(i18n
|
||||
"discourse_rewind.reports.reading_time.book_comparison"
|
||||
readingTitme=this.readTimeString
|
||||
bookTitle=@report.data.book
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="reading-time__book">
|
||||
<div class="book">
|
||||
<img
|
||||
alt=""
|
||||
src="/plugins/discourse-rewind/images/books/{{@report.data.isbn}}.jpg"
|
||||
/>
|
||||
</div>
|
||||
{{#if @report.data.series}}
|
||||
<div class="book-series one"></div>
|
||||
<div class="book-series two"></div>
|
||||
<div class="book-series three"></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class TimeOfDayActivity extends Component {
|
||||
@service currentUser;
|
||||
|
||||
@tracked isPlaying = false;
|
||||
@tracked playbackProgress = 0;
|
||||
@tracked isGlitching = false;
|
||||
|
||||
audioContext = null;
|
||||
audioSource = null;
|
||||
playbackTimeout = null;
|
||||
stereoPanner = null;
|
||||
animationFrame = null;
|
||||
hasGlitched = false;
|
||||
|
||||
SVG_WIDTH = 1200;
|
||||
SVG_HEIGHT = 200;
|
||||
SVG_PADDING = 40;
|
||||
BIT_CRUSH_STEPS = 5;
|
||||
|
||||
get activityByHour() {
|
||||
return this.args.report?.data?.activity_by_hour ?? {};
|
||||
}
|
||||
|
||||
get mostActiveHour() {
|
||||
return this.args.report?.data?.most_active_hour ?? 0;
|
||||
}
|
||||
|
||||
get maxActivity() {
|
||||
const counts = Object.values(this.activityByHour);
|
||||
return Math.max(...counts, 1);
|
||||
}
|
||||
|
||||
get plotDimensions() {
|
||||
return {
|
||||
width: this.SVG_WIDTH,
|
||||
height: this.SVG_HEIGHT,
|
||||
padding: this.SVG_PADDING,
|
||||
plotWidth: this.SVG_WIDTH - this.SVG_PADDING * 2,
|
||||
plotHeight: this.SVG_HEIGHT - this.SVG_PADDING * 2,
|
||||
};
|
||||
}
|
||||
|
||||
calculatePoint(hour) {
|
||||
const { height, padding, plotWidth, plotHeight } = this.plotDimensions;
|
||||
const count = this.activityByHour[hour] || 0;
|
||||
const x = padding + (hour / 23) * plotWidth;
|
||||
const y = height - padding - (count / this.maxActivity) * plotHeight;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
get personalizedAudioParams() {
|
||||
const username = this.currentUser?.username || "default";
|
||||
|
||||
// convert username to seed
|
||||
const hash = (str) => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h * 31 + str.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise
|
||||
}
|
||||
return Math.abs(h);
|
||||
};
|
||||
|
||||
const seed = hash(username);
|
||||
|
||||
// Three distinct scales for variety
|
||||
const scales = [
|
||||
// C minor pentatonic
|
||||
[
|
||||
130.81, 155.56, 174.61, 196.0, 233.08, 261.63, 311.13, 349.23, 392.0,
|
||||
466.16, 523.25,
|
||||
],
|
||||
// C major pentatonic
|
||||
[
|
||||
130.81, 146.83, 164.81, 196.0, 220.0, 261.63, 293.66, 329.63, 392.0,
|
||||
440.0, 523.25,
|
||||
],
|
||||
// Blues scale
|
||||
[
|
||||
130.81, 155.56, 164.81, 174.61, 196.0, 233.08, 261.63, 311.13, 329.63,
|
||||
349.23, 392.0,
|
||||
],
|
||||
];
|
||||
|
||||
const harmonyRatios = [1.5, 1.25, 2.0]; // Perfect fifth, major third, octave
|
||||
|
||||
return {
|
||||
scale: scales[seed % scales.length],
|
||||
harmonyRatio: harmonyRatios[(seed >> 4) % harmonyRatios.length], // eslint-disable-line no-bitwise
|
||||
};
|
||||
}
|
||||
|
||||
get waveformPath() {
|
||||
const points = Array.from({ length: 24 }, (_, hour) =>
|
||||
this.calculatePoint(hour)
|
||||
);
|
||||
|
||||
const tension = 0.3;
|
||||
let path = `M ${points[0].x} ${points[0].y}`;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(i - 1, 0)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(i + 2, points.length - 1)];
|
||||
|
||||
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension;
|
||||
const cp1y = p1.y + ((p2.y - p0.y) / 6) * tension;
|
||||
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension;
|
||||
const cp2y = p2.y - ((p3.y - p1.y) / 6) * tension;
|
||||
|
||||
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
get waveformPoints() {
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const { x, y } = this.calculatePoint(hour);
|
||||
const isActive = hour === this.mostActiveHour;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
hour,
|
||||
radius: isActive ? 6 : 2.5,
|
||||
class: isActive ? "oscilloscope__dot active" : "oscilloscope__dot",
|
||||
showLabel: hour % 3 === 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get gridLines() {
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
y: this.SVG_PADDING + (i * (this.SVG_HEIGHT - 2 * this.SVG_PADDING)) / 4,
|
||||
style: htmlSafe(`opacity: ${i === 0 || i === 4 ? 0.3 : 0.15}`),
|
||||
}));
|
||||
}
|
||||
|
||||
get playbackPosition() {
|
||||
if (!this.isPlaying) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { height, padding, plotWidth, plotHeight } = this.plotDimensions;
|
||||
|
||||
const currentHour = this.playbackProgress * 23;
|
||||
const hourIndex = Math.floor(currentHour);
|
||||
const nextHourIndex = Math.min(hourIndex + 1, 23);
|
||||
const t = currentHour - hourIndex;
|
||||
|
||||
const currentActivity = this.activityByHour[hourIndex] || 0;
|
||||
const nextActivity = this.activityByHour[nextHourIndex] || 0;
|
||||
const activity = currentActivity + (nextActivity - currentActivity) * t;
|
||||
|
||||
const x = padding + (currentHour / 23) * plotWidth;
|
||||
const y = height - padding - (activity / this.maxActivity) * plotHeight;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
formatHour(hour) {
|
||||
const hourNum = parseInt(hour, 10);
|
||||
const period = hourNum >= 12 ? "PM" : "AM";
|
||||
const displayHour =
|
||||
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
return `${displayHour}${period}`;
|
||||
}
|
||||
|
||||
@action
|
||||
stopWaveform() {
|
||||
if (this.audioSource) {
|
||||
this.audioSource.stop();
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
if (this.playbackTimeout) {
|
||||
clearTimeout(this.playbackTimeout);
|
||||
}
|
||||
this.isPlaying = false;
|
||||
this.playbackProgress = 0;
|
||||
this.isGlitching = false;
|
||||
this.hasGlitched = false;
|
||||
this.audioContext = null;
|
||||
this.audioSource = null;
|
||||
this.playbackTimeout = null;
|
||||
this.stereoPanner = null;
|
||||
}
|
||||
|
||||
@action
|
||||
async playWaveform() {
|
||||
if (this.isPlaying) {
|
||||
this.stopWaveform();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.playbackProgress = 0;
|
||||
|
||||
try {
|
||||
this.audioContext = new (
|
||||
window.AudioContext || window.webkitAudioContext
|
||||
)();
|
||||
|
||||
// Musical timing: 24 hours at 200 BPM
|
||||
const duration = 7.2; // (24 / 200) * 60 seconds
|
||||
|
||||
const sampleRate = this.audioContext.sampleRate;
|
||||
const numSamples = duration * sampleRate;
|
||||
|
||||
const buffer = this.audioContext.createBuffer(1, numSamples, sampleRate);
|
||||
const channelData = buffer.getChannelData(0);
|
||||
|
||||
const params = this.personalizedAudioParams;
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const samplesPerHour = numSamples / 24;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const hourIndex = Math.floor(i / samplesPerHour);
|
||||
const nextHourIndex = Math.min(hourIndex + 1, 23);
|
||||
const t = (i % samplesPerHour) / samplesPerHour;
|
||||
|
||||
// Get activity for current and next hour
|
||||
const currentActivity = this.activityByHour[hours[hourIndex]] || 0;
|
||||
const nextActivity = this.activityByHour[hours[nextHourIndex]] || 0;
|
||||
|
||||
// Interpolate between hours
|
||||
const activity = currentActivity + (nextActivity - currentActivity) * t;
|
||||
|
||||
// Map activity to personalized musical scale
|
||||
const scale = params.scale;
|
||||
const normalizedActivity = activity / this.maxActivity;
|
||||
|
||||
// Map to scale index with smooth interpolation
|
||||
const scalePosition = normalizedActivity * (scale.length - 1);
|
||||
const lowerIndex = Math.floor(scalePosition);
|
||||
const upperIndex = Math.min(lowerIndex + 1, scale.length - 1);
|
||||
const blend = scalePosition - lowerIndex;
|
||||
|
||||
let frequency =
|
||||
scale[lowerIndex] + (scale[upperIndex] - scale[lowerIndex]) * blend;
|
||||
|
||||
const time = i / sampleRate;
|
||||
|
||||
// Generate triangle waveform for retro feel
|
||||
const generateWave = (freq) => {
|
||||
const sine = Math.sin(2 * Math.PI * freq * time);
|
||||
return (2 / Math.PI) * Math.asin(sine);
|
||||
};
|
||||
|
||||
// Generate main voice and harmony
|
||||
const mainWave = generateWave(frequency);
|
||||
const harmonyWave = generateWave(frequency * params.harmonyRatio);
|
||||
|
||||
// Mix voices (70/30 split)
|
||||
const mixedWave = mainWave * 0.7 + harmonyWave * 0.3;
|
||||
|
||||
// Add bit crushing effect for retro feel
|
||||
const crushed =
|
||||
Math.round(mixedWave * this.BIT_CRUSH_STEPS) / this.BIT_CRUSH_STEPS;
|
||||
|
||||
// Add minimal noise (sparkle at peak hour)
|
||||
const peakHourTime = (this.mostActiveHour / 23) * duration;
|
||||
const peakWindow = 0.2;
|
||||
const isPeakMoment =
|
||||
Math.abs(time - peakHourTime) < peakWindow && time >= peakHourTime;
|
||||
|
||||
const noise = (Math.random() - 0.5) * (isPeakMoment ? 0.08 : 0.02);
|
||||
|
||||
// Reduce volume for zero/very low activity sections
|
||||
const lowActivityVolume = normalizedActivity < 0.05 ? 0.3 : 1;
|
||||
|
||||
// Output with all effects applied
|
||||
channelData[i] = (crushed * 0.15 + noise) * lowActivityVolume;
|
||||
}
|
||||
|
||||
// Create source and play
|
||||
this.audioSource = this.audioContext.createBufferSource();
|
||||
this.audioSource.buffer = buffer;
|
||||
|
||||
// Add stereo panner (pan from left to right as time progresses)
|
||||
this.stereoPanner = this.audioContext.createStereoPanner();
|
||||
this.stereoPanner.pan.value = -1; // Start at left
|
||||
|
||||
// Add filter for retro feel
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = 2500; // Tame the highs
|
||||
|
||||
// Simple delay for subtle depth
|
||||
const delay = this.audioContext.createDelay();
|
||||
delay.delayTime.value = 0.15; // 150ms delay
|
||||
|
||||
const delayGain = this.audioContext.createGain();
|
||||
delayGain.gain.value = 0.2; // Subtle echo
|
||||
|
||||
// Connect audio graph: source -> filter -> panner + delay feedback
|
||||
this.audioSource.connect(filter);
|
||||
filter.connect(this.stereoPanner);
|
||||
|
||||
// Add subtle delay feedback
|
||||
filter.connect(delay);
|
||||
delay.connect(delayGain);
|
||||
delayGain.connect(this.stereoPanner);
|
||||
|
||||
this.stereoPanner.connect(this.audioContext.destination);
|
||||
|
||||
this.audioSource.start();
|
||||
|
||||
// Animate playback progress and stereo panning
|
||||
const startTime = Date.now();
|
||||
const animate = () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
this.playbackProgress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Pan from left (-1) to right (1) as we progress
|
||||
if (this.stereoPanner) {
|
||||
this.stereoPanner.pan.value = -1 + this.playbackProgress * 2;
|
||||
}
|
||||
|
||||
// Trigger visual glitch when hitting peak hour
|
||||
const peakHourProgress = this.mostActiveHour / 23;
|
||||
if (
|
||||
!this.hasGlitched &&
|
||||
this.playbackProgress >= peakHourProgress &&
|
||||
this.playbackProgress < peakHourProgress + 0.05
|
||||
) {
|
||||
this.isGlitching = true;
|
||||
this.hasGlitched = true;
|
||||
setTimeout(() => {
|
||||
this.isGlitching = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (this.playbackProgress < 1) {
|
||||
this.animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
this.animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
// Reset playing state when done
|
||||
this.playbackTimeout = setTimeout(() => {
|
||||
this.stopWaveform();
|
||||
}, duration * 1000);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error playing waveform:", error);
|
||||
this.stopWaveform();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --time-of-day-activity">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.time_of_day_activity.title"
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<div class="rewind-card">
|
||||
<div
|
||||
class="time-of-day__oscilloscope
|
||||
{{if this.isGlitching '--glitching'}}"
|
||||
>
|
||||
<DButton
|
||||
@action={{this.playWaveform}}
|
||||
@icon={{if this.isPlaying "volume-xmark" "volume-high"}}
|
||||
class="oscilloscope__play-btn {{if this.isPlaying '--playing'}}"
|
||||
@title={{if
|
||||
this.isPlaying
|
||||
"discourse_rewind.reports.time_of_day_activity.stop_button"
|
||||
"discourse_rewind.reports.time_of_day_activity.play_button"
|
||||
}}
|
||||
/>
|
||||
<svg viewBox="0 0 1200 200" class="oscilloscope__svg">
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-strong">
|
||||
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{{#each this.gridLines as |gridLine|}}
|
||||
<line
|
||||
x1="40"
|
||||
y1={{gridLine.y}}
|
||||
x2="1160"
|
||||
y2={{gridLine.y}}
|
||||
class="oscilloscope__grid-line"
|
||||
style={{gridLine.style}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#each this.waveformPoints as |point|}}
|
||||
{{#if point.showLabel}}
|
||||
<line
|
||||
x1={{point.x}}
|
||||
y1="40"
|
||||
x2={{point.x}}
|
||||
y2="160"
|
||||
class="oscilloscope__grid-line --vertical"
|
||||
/>
|
||||
<text
|
||||
x={{point.x}}
|
||||
y="180"
|
||||
class="oscilloscope__time-label"
|
||||
>{{this.formatHour point.hour}}</text>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<path d={{this.waveformPath}} class="oscilloscope__waveform" />
|
||||
|
||||
{{#each this.waveformPoints as |point|}}
|
||||
<circle
|
||||
cx={{point.x}}
|
||||
cy={{point.y}}
|
||||
r={{point.radius}}
|
||||
class={{point.class}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if this.playbackPosition}}
|
||||
<circle
|
||||
cx={{this.playbackPosition.x}}
|
||||
cy={{this.playbackPosition.y}}
|
||||
r="12"
|
||||
class="oscilloscope__playback-dot"
|
||||
/>
|
||||
{{/if}}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import WordCard from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words/word-card";
|
||||
|
||||
export default class WordCards extends Component {
|
||||
get topWords() {
|
||||
return this.args.report.data.sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="rewind-report-page --top-words">
|
||||
<div class="rewind-report-container">
|
||||
<h2 class="rewind-report-title">{{i18n
|
||||
"discourse_rewind.reports.top_words.title"
|
||||
}}</h2>
|
||||
<div class="cards-container">
|
||||
{{#each this.topWords as |entry index|}}
|
||||
<WordCard
|
||||
@word={{entry.word}}
|
||||
@count={{entry.score}}
|
||||
@index={{index}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import emoji from "discourse/helpers/emoji";
|
||||
import discourseLater from "discourse/lib/later";
|
||||
|
||||
const MYSTERY_EMOJIS = [
|
||||
"floppy_disk",
|
||||
"videocassette",
|
||||
"computer_disk",
|
||||
"pager",
|
||||
"fax",
|
||||
];
|
||||
|
||||
const BACKGROUND_COLORS = [
|
||||
[
|
||||
"251, 245, 175",
|
||||
"40, 171, 226",
|
||||
"12, 166, 78",
|
||||
"240, 121, 74",
|
||||
"232, 74, 81",
|
||||
],
|
||||
[
|
||||
"197, 193, 140",
|
||||
"39, 137, 178",
|
||||
"17, 138, 68",
|
||||
"188, 105, 65",
|
||||
"183, 64, 70",
|
||||
],
|
||||
];
|
||||
|
||||
export default class WordCard extends Component {
|
||||
get randomStyle() {
|
||||
return `--rand: ${Math.random()}`;
|
||||
}
|
||||
|
||||
get mysteryData() {
|
||||
return {
|
||||
emoji: MYSTERY_EMOJIS[this.args.index],
|
||||
color: `--mystery-color-light: ${
|
||||
BACKGROUND_COLORS[0][this.args.index]
|
||||
}; --mystery-color-dark: ${BACKGROUND_COLORS[1][this.args.index]};`,
|
||||
};
|
||||
}
|
||||
|
||||
get longWord() {
|
||||
return this.args.word.length >= 5;
|
||||
}
|
||||
|
||||
get cardStyle() {
|
||||
return htmlSafe(`${this.randomStyle}; ${this.mysteryData.color};`);
|
||||
}
|
||||
|
||||
@action
|
||||
registerCardContainer(element) {
|
||||
this.cardContainer = element;
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick() {
|
||||
this.cardContainer.classList.toggle("flipped");
|
||||
}
|
||||
|
||||
@action
|
||||
handleLeave() {
|
||||
const cardContainer = this.cardContainer;
|
||||
cardContainer.classList.toggle("mouseleave");
|
||||
discourseLater(() => {
|
||||
cardContainer.classList.remove("mouseleave");
|
||||
}, 100);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
{{on "click" this.handleClick}}
|
||||
{{on "mouseleave" this.handleLeave}}
|
||||
class={{concatClass
|
||||
"rewind-card__wrapper"
|
||||
(if this.longWord "--long-word")
|
||||
}}
|
||||
style={{this.cardStyle}}
|
||||
{{didInsert this.registerCardContainer}}
|
||||
role="button"
|
||||
>
|
||||
<div class="rewind-card__inner">
|
||||
<div class="rewind-card --front">
|
||||
<span class="rewind-card__image tl">
|
||||
{{emoji this.mysteryData.emoji}}
|
||||
</span>
|
||||
<span class="rewind-card__image cr">
|
||||
{{emoji this.mysteryData.emoji}}
|
||||
</span>
|
||||
<span class="rewind-card__image br">
|
||||
{{emoji this.mysteryData.emoji}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rewind-card --back">
|
||||
<span class="rewind-card__title">{{@word}}</span>
|
||||
<span class="rewind-card__data">{{@count}}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import number from "discourse/helpers/number";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { fetchRewindYear } from "../../connectors/before-panel-body/rewind-callout";
|
||||
|
||||
export default class WritingAnalysis extends Component {
|
||||
@tracked currentColorIndex = 0;
|
||||
|
||||
terminalColors = ["#0f0", "#ffbf00", "#00ffff", "#ff00ff"];
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
@action
|
||||
setupKeyListener(element) {
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
@action
|
||||
teardownKeyListener() {
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
if (event.key === "F1") {
|
||||
event.preventDefault();
|
||||
this.cycleColor();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
cycleColor() {
|
||||
this.currentColorIndex =
|
||||
(this.currentColorIndex + 1) % this.terminalColors.length;
|
||||
const newColor = this.terminalColors[this.currentColorIndex];
|
||||
document.documentElement.style.setProperty("--rewind-green", newColor);
|
||||
}
|
||||
|
||||
get scoreLabel() {
|
||||
const score = this.args.report.data.readability_score;
|
||||
const randomNum = Math.floor(Math.random() * 4) + 1;
|
||||
|
||||
switch (true) {
|
||||
case score >= 80 && score <= 100:
|
||||
return i18n(
|
||||
`discourse_rewind.reports.writing_analysis.readability_score.over_80.${randomNum}`
|
||||
);
|
||||
case score >= 60 && score < 80:
|
||||
return i18n(
|
||||
`discourse_rewind.reports.writing_analysis.readability_score.over_60.${randomNum}`
|
||||
);
|
||||
case score >= 40 && score < 60:
|
||||
return i18n(
|
||||
`discourse_rewind.reports.writing_analysis.readability_score.over_40.${randomNum}`
|
||||
);
|
||||
case score >= 20 && score < 40:
|
||||
return i18n(
|
||||
`discourse_rewind.reports.writing_analysis.readability_score.over_20.${randomNum}`
|
||||
);
|
||||
default:
|
||||
return i18n(
|
||||
`discourse_rewind.reports.writing_analysis.readability_score.over_0.${randomNum}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get minimumDataThresholdMet() {
|
||||
return (
|
||||
this.args.report.data.total_words >= 100 &&
|
||||
this.args.report.data.total_posts >= 5
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.minimumDataThresholdMet}}
|
||||
<div
|
||||
class="rewind-report-page --writing-analysis"
|
||||
{{didInsert this.setupKeyListener}}
|
||||
{{willDestroy this.teardownKeyListener}}
|
||||
>
|
||||
<h2 class="rewind-report-title">
|
||||
{{i18n "discourse_rewind.reports.writing_analysis.title"}}
|
||||
</h2>
|
||||
|
||||
<div class="writing-analysis">
|
||||
|
||||
<div class="writing-analysis__menubar">
|
||||
<span class="writing-analysis__menu-item">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.menu_file"
|
||||
}}</span>
|
||||
<span class="writing-analysis__menu-item">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.menu_other"
|
||||
}}</span>
|
||||
<span class="writing-analysis__menu-item">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.menu_additional"
|
||||
}}</span>
|
||||
<span
|
||||
class="writing-analysis__menu-item writing-analysis__menu-item--right"
|
||||
>{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.menu_opening"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="writing-analysis__frame">
|
||||
|
||||
<div class="writing-analysis__header-row">
|
||||
|
||||
<div class="writing-analysis__helpbox">
|
||||
{{i18n "discourse_rewind.reports.writing_analysis.help_text"}}
|
||||
</div>
|
||||
|
||||
<div class="writing-analysis__release">
|
||||
<div class="writing-analysis__release-name">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.app_name"
|
||||
}}</div>
|
||||
<div class="writing-analysis__release-meta">
|
||||
{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.release_info"
|
||||
rewindYear=(fetchRewindYear)
|
||||
}}
|
||||
<span><3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="writing-analysis__stats">
|
||||
|
||||
<div class="writing-analysis__stats-col">
|
||||
<div class="writing-analysis__stats-label">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.total_words"
|
||||
}}</div>
|
||||
<div
|
||||
class="writing-analysis__stats-value"
|
||||
>{{@report.data.total_words}}</div>
|
||||
|
||||
<div class="writing-analysis__stats-label">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.total_posts"
|
||||
}}</div>
|
||||
<div
|
||||
class="writing-analysis__stats-value"
|
||||
>{{@report.data.total_posts}}</div>
|
||||
</div>
|
||||
|
||||
<div class="writing-analysis__stats-col">
|
||||
<div class="writing-analysis__stats-label">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.avg_post_length"
|
||||
}}</div>
|
||||
<div class="writing-analysis__stats-value">{{number
|
||||
@report.data.average_post_length
|
||||
}}</div>
|
||||
|
||||
<div class="writing-analysis__stats-label">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.readability_score_label"
|
||||
}}</div>
|
||||
<div class="writing-analysis__stats-value">{{number
|
||||
@report.data.readability_score
|
||||
}}</div>
|
||||
</div>
|
||||
|
||||
<div class="writing-analysis__stats-col">
|
||||
<div class="writing-analysis__stats-label">{{i18n
|
||||
"discourse_rewind.reports.writing_analysis.readability_level"
|
||||
}}</div>
|
||||
<div
|
||||
class="writing-analysis__stats-value"
|
||||
>{{this.scoreLabel}}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ActivityCalendar from "discourse/plugins/discourse-rewind/discourse/components/reports/activity-calendar";
|
||||
import AiUsage from "discourse/plugins/discourse-rewind/discourse/components/reports/ai-usage";
|
||||
import Assignments from "discourse/plugins/discourse-rewind/discourse/components/reports/assignments";
|
||||
import BestPosts from "discourse/plugins/discourse-rewind/discourse/components/reports/best-posts";
|
||||
import BestTopics from "discourse/plugins/discourse-rewind/discourse/components/reports/best-topics";
|
||||
import ChatUsage from "discourse/plugins/discourse-rewind/discourse/components/reports/chat-usage";
|
||||
// import FavoriteGifs from "discourse/plugins/discourse-rewind/discourse/components/reports/favorite-gifs";
|
||||
import FBFF from "discourse/plugins/discourse-rewind/discourse/components/reports/fbff";
|
||||
import RewindHeader from "discourse/plugins/discourse-rewind/discourse/components/reports/header";
|
||||
import Invites from "discourse/plugins/discourse-rewind/discourse/components/reports/invites";
|
||||
import MostViewedCategories from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-categories";
|
||||
import MostViewedTags from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-tags";
|
||||
import NewUserInteractions from "discourse/plugins/discourse-rewind/discourse/components/reports/new-user-interactions";
|
||||
import Reactions from "discourse/plugins/discourse-rewind/discourse/components/reports/reactions";
|
||||
import ReadingTime from "discourse/plugins/discourse-rewind/discourse/components/reports/reading-time";
|
||||
import TimeOfDayActivity from "discourse/plugins/discourse-rewind/discourse/components/reports/time-of-day-activity";
|
||||
import TopWords from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words";
|
||||
import WritingAnalysis from "discourse/plugins/discourse-rewind/discourse/components/reports/writing-analysis";
|
||||
|
||||
export default class Rewind extends Component {
|
||||
@tracked rewind = [];
|
||||
@tracked fullScreen = true;
|
||||
@tracked loadingRewind = false;
|
||||
|
||||
@action
|
||||
registerScrollWrapper(element) {
|
||||
this.scrollWrapper = element;
|
||||
}
|
||||
|
||||
@action
|
||||
async loadRewind() {
|
||||
try {
|
||||
this.loadingRewind = true;
|
||||
this.rewind = await ajax("/rewinds");
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.loadingRewind = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleFullScreen() {
|
||||
this.fullScreen = !this.fullScreen;
|
||||
}
|
||||
|
||||
@action
|
||||
handleEscape(event) {
|
||||
if (this.fullScreen && event.key === "Escape") {
|
||||
this.fullScreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBackdropClick(event) {
|
||||
if (this.fullScreen && event.target === event.currentTarget) {
|
||||
this.fullScreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
registerRewindContainer(element) {
|
||||
this.rewindContainer = element;
|
||||
}
|
||||
|
||||
getReportComponent(identifier) {
|
||||
switch (identifier) {
|
||||
case "fbff":
|
||||
return FBFF;
|
||||
case "reactions":
|
||||
return Reactions;
|
||||
case "top-words":
|
||||
return TopWords;
|
||||
case "best-posts":
|
||||
return BestPosts;
|
||||
case "best-topics":
|
||||
return BestTopics;
|
||||
case "activity-calendar":
|
||||
return ActivityCalendar;
|
||||
case "most-viewed-tags":
|
||||
return MostViewedTags;
|
||||
case "reading-time":
|
||||
return ReadingTime;
|
||||
case "most-viewed-categories":
|
||||
return MostViewedCategories;
|
||||
case "ai-usage":
|
||||
return AiUsage;
|
||||
case "assignments":
|
||||
return Assignments;
|
||||
case "chat-usage":
|
||||
return ChatUsage;
|
||||
// case "favorite-gifs":
|
||||
// return FavoriteGifs;
|
||||
case "invites":
|
||||
return Invites;
|
||||
case "new-user-interactions":
|
||||
return NewUserInteractions;
|
||||
case "time-of-day-activity":
|
||||
return TimeOfDayActivity;
|
||||
case "writing-analysis":
|
||||
return WritingAnalysis;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"rewind-container"
|
||||
(if this.fullScreen "--fullscreen")
|
||||
}}
|
||||
{{didInsert this.loadRewind}}
|
||||
{{on "keydown" this.handleEscape}}
|
||||
{{on "click" this.handleBackdropClick}}
|
||||
{{didInsert this.registerRewindContainer}}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="rewind">
|
||||
<RewindHeader />
|
||||
{{#if this.loadingRewind}}
|
||||
<div class="rewind-loader">
|
||||
<div class="spinner small"></div>
|
||||
<div class="rewind-loader__text">
|
||||
{{i18n "discourse_rewind.loading"}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<DButton
|
||||
class="btn-default rewind__exit-fullscreen-btn --special-kbd"
|
||||
@icon={{if this.fullScreen "discourse-compress" "discourse-expand"}}
|
||||
@action={{this.toggleFullScreen}}
|
||||
/>
|
||||
<div
|
||||
class="rewind__scroll-wrapper"
|
||||
{{didInsert this.registerScrollWrapper}}
|
||||
>
|
||||
|
||||
{{#each this.rewind as |report|}}
|
||||
{{#let
|
||||
(this.getReportComponent report.identifier)
|
||||
as |ReportComponent|
|
||||
}}
|
||||
{{#if ReportComponent}}
|
||||
<div class={{concatClass "rewind-report" report.identifier}}>
|
||||
<ReportComponent @report={{report}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
{{#if this.showPrev}}
|
||||
<DButton
|
||||
class="rewind__prev-btn"
|
||||
@icon="chevron-left"
|
||||
@action={{this.prev}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showNext}}
|
||||
<DButton
|
||||
class="rewind__next-btn"
|
||||
@icon="chevron-right"
|
||||
@action={{this.next}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { service } from "@ember/service";
|
||||
import { TrackedObject } from "@ember-compat/tracked-built-ins";
|
||||
import bodyClass from "discourse/helpers/body-class";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import { fetchRewindYear } from "../before-panel-body/rewind-callout";
|
||||
|
||||
export default class AvatarDecorator extends Component {
|
||||
@service currentUser;
|
||||
|
||||
store = new TrackedObject(
|
||||
new KeyValueStore("discourse_rewind_" + fetchRewindYear())
|
||||
);
|
||||
|
||||
get dismissed() {
|
||||
return this.store.getObject("_dismissed") ?? false;
|
||||
}
|
||||
|
||||
get showDecorator() {
|
||||
return this.currentUser?.is_rewind_active && !this.dismissed;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.showDecorator}}
|
||||
{{bodyClass "rewind-notification-active"}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
// We want to show the previous year's rewind in January
|
||||
// but the current year's rewind in any other month (in
|
||||
// reality, only December).
|
||||
export function fetchRewindYear() {
|
||||
const currentDate = new Date();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
if (currentMonth === 0) {
|
||||
return currentYear - 1;
|
||||
} else {
|
||||
return currentYear;
|
||||
}
|
||||
}
|
||||
|
||||
export default class RewindCallout extends Component {
|
||||
@service router;
|
||||
@service currentUser;
|
||||
|
||||
store = new KeyValueStore("discourse_rewind_" + this.fetchYear);
|
||||
|
||||
get showCallout() {
|
||||
return (
|
||||
this.currentUser?.is_rewind_active &&
|
||||
(this.currentUser?.is_development_env || !this.dismissed)
|
||||
);
|
||||
}
|
||||
|
||||
get dismissed() {
|
||||
return this.store.getObject("_dismissed") ?? false;
|
||||
}
|
||||
|
||||
@action
|
||||
openRewind() {
|
||||
this.store.setObject({ key: "_dismissed", value: true });
|
||||
this.router.transitionTo("/my/activity/rewind");
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.showCallout}}
|
||||
<div class="rewind-callout__container">
|
||||
<DButton
|
||||
@action={{this.openRewind}}
|
||||
class="rewind-callout btn-transparent"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 515.48 76.72">
|
||||
<path d="M0 0h515.48v76.72H0z" style="fill:#faf7e4" />
|
||||
<path
|
||||
d="M42.27 12.67c-12.8 0-23.58 10.38-23.58 23.18v24l23.57-.02c12.8 0 23.18-10.78 23.18-23.58S55.05 12.67 42.26 12.67z"
|
||||
style="fill:#010101"
|
||||
/>
|
||||
<path
|
||||
d="M42.51 21.64c-7.94 0-14.37 6.44-14.36 14.38 0 2.39.6 4.73 1.73 6.83l-2.6 8.36 9.34-2.11a14.36 14.36 0 0 0 15.85-2.73 14.371 14.371 0 0 0-9.94-24.74h-.02Z"
|
||||
style="fill:#fcf6b2"
|
||||
/>
|
||||
<path
|
||||
d="M53.75 44.9a14.356 14.356 0 0 1-17.13 4.18l-9.34 2.14 9.5-1.12c6.3 3.69 14.37 2.07 18.75-3.77s3.68-14.04-1.62-19.06c3.98 5.22 3.92 12.49-.16 17.63"
|
||||
style="fill:#29abe2"
|
||||
/>
|
||||
<path
|
||||
d="M52.94 42.17c-3.52 5.55-10.35 7.99-16.6 5.95l-9.06 3.1 9.34-2.11c6.65 3 14.49.54 18.24-5.72s2.2-14.34-3.59-18.77c4.51 4.78 5.2 12.01 1.68 17.55Z"
|
||||
style="fill:#10a94d"
|
||||
/>
|
||||
<path
|
||||
d="M30.74 43.17c-2.6-6.27-.46-13.51 5.14-17.35S49 22.58 53.92 27.26c-4.55-5.98-12.94-7.44-19.24-3.35s-8.39 12.34-4.8 18.93l-2.6 8.36 3.46-8.04Z"
|
||||
style="fill:#f15f25"
|
||||
/>
|
||||
<path
|
||||
d="M29.88 42.85a14.36 14.36 0 0 1 3.31-17.77 14.35 14.35 0 0 1 18.07-.46c-5.16-5.43-13.63-5.99-19.45-1.28-5.83 4.71-7.05 13.11-2.82 19.29l-1.71 8.59zM181.65 0l71.41 76.72h34.22L215.88 0z"
|
||||
style="fill:#d0222b"
|
||||
/>
|
||||
<path
|
||||
d="m215.88 0 71.4 76.72h34.23L250.11 0z"
|
||||
style="fill:#f15f25"
|
||||
/>
|
||||
<path
|
||||
d="m250.11 0 71.4 76.72h34.23L284.33 0z"
|
||||
style="fill:#fcf6b2"
|
||||
/>
|
||||
<path
|
||||
d="m284.33 0 71.41 76.72h34.22L318.56 0z"
|
||||
style="fill:#10a94d"
|
||||
/>
|
||||
<path
|
||||
d="m318.56 0 71.4 76.72h34.23L352.79 0z"
|
||||
style="fill:#29abe2"
|
||||
/>
|
||||
|
||||
<text
|
||||
x="108"
|
||||
y="22"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
style="font-size: 20px; transform: skewX(15deg); fill: #010101; letter-spacing: .025em;"
|
||||
>{{i18n "discourse_rewind.title"}}</text>
|
||||
|
||||
<text
|
||||
x="115"
|
||||
y="50"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
style="font-size: 32px; font-weight: bold; fill: #010101;"
|
||||
>{{fetchRewindYear}}</text>
|
||||
|
||||
</svg>
|
||||
|
||||
<span class="btn no-text --special-kbd">
|
||||
{{icon "play"}}
|
||||
</span>
|
||||
|
||||
</DButton>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { service } from "@ember/service";
|
||||
import DNavigationItem from "discourse/components/d-navigation-item";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class RewindTab extends Component {
|
||||
@service currentUser;
|
||||
|
||||
<template>
|
||||
{{#if this.currentUser.is_rewind_active}}
|
||||
<DNavigationItem
|
||||
@route="userActivity.rewind"
|
||||
@ariaCurrentContext="subNav"
|
||||
class="user-nav__activity-rewind"
|
||||
>
|
||||
{{icon "repeat"}}
|
||||
<span>{{i18n "discourse_rewind.title"}}</span>
|
||||
</DNavigationItem>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { LinkTo } from "@ember/routing";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class RewindPreferencesNav extends Component {
|
||||
static shouldRender(args, context) {
|
||||
return context.siteSettings.discourse_rewind_enabled;
|
||||
}
|
||||
|
||||
<template>
|
||||
<li class="user-nav__preferences-rewind">
|
||||
<LinkTo @route="preferences.rewind">
|
||||
{{icon "repeat"}}
|
||||
<span>{{i18n "discourse_rewind.title"}}</span>
|
||||
</LinkTo>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import Controller from "@ember/controller";
|
||||
import { action } from "@ember/object";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const REWIND_ATTRS = ["discourse_rewind_disabled"];
|
||||
|
||||
export default class PreferencesRewindController extends Controller {
|
||||
subpageTitle = i18n("discourse_rewind.title");
|
||||
|
||||
@action
|
||||
save() {
|
||||
this.set("saved", false);
|
||||
return this.model
|
||||
.save(REWIND_ATTRS)
|
||||
.then(() => {
|
||||
this.set("saved", true);
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
|
||||
const FIELD_NAME = "discourse_rewind_disabled";
|
||||
|
||||
export default {
|
||||
name: "rewind-user-options",
|
||||
|
||||
initialize(container) {
|
||||
withPluginApi((api) => {
|
||||
const siteSettings = container.lookup("service:site-settings");
|
||||
if (siteSettings.discourse_rewind_enabled) {
|
||||
api.addSaveableUserOptionField(FIELD_NAME);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
resource: "user.preferences",
|
||||
|
||||
map() {
|
||||
this.route("rewind");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function () {
|
||||
this.route(
|
||||
"user",
|
||||
{ path: "/u/:username", resetNamespace: true },
|
||||
function () {
|
||||
this.route("userActivity", function () {
|
||||
this.route("rewind");
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class UserActivityRewind extends DiscourseRoute {
|
||||
templateName = "user/rewind";
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Input } from "@ember/component";
|
||||
import SaveControls from "discourse/components/save-controls";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default <template>
|
||||
<label class="control-label">{{i18n "discourse_rewind.title"}}</label>
|
||||
|
||||
<div
|
||||
class="control-group rewind-setting"
|
||||
data-setting-name="user_discourse_rewind_disabled"
|
||||
>
|
||||
<label class="controls">
|
||||
<Input
|
||||
id="user_discourse_rewind_disabled"
|
||||
@type="checkbox"
|
||||
@checked={{@controller.model.user_option.discourse_rewind_disabled}}
|
||||
/>
|
||||
{{i18n "discourse_rewind.preferences.disable_rewind"}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SaveControls
|
||||
@model={{@controller.model}}
|
||||
@action={{@controller.save}}
|
||||
@saved={{@controller.saved}}
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
import Rewind from "../../components/rewind";
|
||||
|
||||
export default <template><Rewind /></template>
|
||||
@@ -0,0 +1,25 @@
|
||||
@import "variables";
|
||||
@import "rewind";
|
||||
@import "report";
|
||||
@import "card";
|
||||
@import "post-received-reactions";
|
||||
@import "post-used-reactions";
|
||||
@import "activity-calendar";
|
||||
@import "best-posts";
|
||||
@import "best-topics";
|
||||
@import "top-words";
|
||||
@import "most-viewed-tags";
|
||||
@import "most-viewed-categories";
|
||||
@import "fonts";
|
||||
@import "reading-time";
|
||||
@import "fbff";
|
||||
@import "writing-analysis";
|
||||
@import "rewind-header";
|
||||
@import "rewind-callout";
|
||||
@import "folder-styles";
|
||||
@import "time-of-day-activity";
|
||||
@import "chat-usage";
|
||||
@import "new-user-interactions";
|
||||
@import "ai-usage";
|
||||
@import "assignments";
|
||||
@import "invites";
|
||||
@@ -0,0 +1,73 @@
|
||||
.--activity-calendar {
|
||||
margin-bottom: 2em;
|
||||
|
||||
.rewind-report-title {
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rewind-calendar {
|
||||
border-collapse: unset;
|
||||
border-spacing: 3px;
|
||||
width: 100%;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
border-spacing: 1px;
|
||||
}
|
||||
|
||||
tr {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-header-cell {
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rewind-calendar-cell {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
&.--empty {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
|
||||
&.--low {
|
||||
background: color-mix(in srgb, var(--rewind-green) 40%, transparent);
|
||||
}
|
||||
|
||||
&.--medium {
|
||||
background: color-mix(in srgb, var(--rewind-green) 70%, transparent);
|
||||
}
|
||||
|
||||
&.--high {
|
||||
background: var(--rewind-green);
|
||||
animation: pulse-high 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-high {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgb(0, 255, 0, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 8px 2px var(--rewind-green);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.--ai-usage {
|
||||
.rewind-report-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.matrix-container {
|
||||
position: relative;
|
||||
min-height: 600px;
|
||||
background: var(--rewind-black);
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
outline: inset 1px solid var(--rewind-green);
|
||||
outline-offset: 5px;
|
||||
border: 2px solid var(--rewind-green);
|
||||
box-shadow: 0 0 20px 10px rgb(0 255 0 / 0.2);
|
||||
}
|
||||
|
||||
.matrix-rain {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.matrix-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 3em 1em 1em;
|
||||
color: var(--rewind-green);
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
.matrix-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 2em;
|
||||
letter-spacing: 4px;
|
||||
text-shadow: 0 0 5px var(--rewind-green);
|
||||
|
||||
.matrix-subhead {
|
||||
font-size: var(--font-down-5);
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.matrix-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 3em;
|
||||
flex-wrap: wrap;
|
||||
gap: 2em;
|
||||
}
|
||||
|
||||
.matrix-stat {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.matrix-stat__label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 0.5em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.matrix-stat__value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px var(--rewind-green);
|
||||
}
|
||||
|
||||
.matrix-section {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.matrix-section__title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1em;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 0 5px var(--rewind-green);
|
||||
}
|
||||
|
||||
.matrix-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.matrix-list__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75em 1em;
|
||||
background: rgb(0, 255, 0, 0.05);
|
||||
border: 1px solid rgb(0, 255, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.matrix-list__name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.matrix-list__count {
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px var(--rewind-green);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
.--assignments {
|
||||
.sticky-board {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 1em 0;
|
||||
z-index: 9; // above the scanlines
|
||||
}
|
||||
|
||||
.sticky-note {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
box-shadow: 3px 3px 8px rgb(0 0 0 / 0.2);
|
||||
position: relative;
|
||||
font-family: "Brush Script MT", cursive, sans-serif;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
background: linear-gradient(to bottom, rgb(0 0 0 / 0.05), transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.--yellow {
|
||||
background: linear-gradient(to bottom, #fef9c3 0%, #fde68a 100%);
|
||||
}
|
||||
|
||||
&.--pink {
|
||||
background: linear-gradient(to bottom, #fecaca 0%, #fca5a5 100%);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 0 20px 20px;
|
||||
border-color: transparent transparent #000 transparent;
|
||||
box-shadow: -2px 2px 4px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.--blue {
|
||||
background: linear-gradient(to bottom, #bfdbfe 0%, #93c5fd 100%);
|
||||
}
|
||||
|
||||
&.--green {
|
||||
background: linear-gradient(to bottom, #bbf7d0 0%, #86efac 100%);
|
||||
}
|
||||
|
||||
&.--orange {
|
||||
background: linear-gradient(to bottom, #fed7aa 0%, #fdba74 100%);
|
||||
}
|
||||
|
||||
&.--rotate-left {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
&.--rotate-right {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
&.--rotate-left-small {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
&.--rotate-right-small {
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-note__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sticky-note__title {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1;
|
||||
padding: 0 5em;
|
||||
}
|
||||
|
||||
.sticky-note__value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
|
||||
.number {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
.--best-posts {
|
||||
margin-bottom: 1em;
|
||||
|
||||
.rewind-report-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, calc(32% - (1em / 3)));
|
||||
gap: 1em;
|
||||
|
||||
@media screen and (width <= 580px) {
|
||||
padding: 1em;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
|
||||
@media screen and (width >= 550px) {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
border-radius: 0;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
position: relative;
|
||||
color: var(--rewind-black);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
max-width: 700px;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
225deg,
|
||||
transparent 13px,
|
||||
var(--rewind-white) 13px
|
||||
);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
border: 10px solid transparent;
|
||||
border-left: 10px solid var(--rewind-white);
|
||||
border-bottom: 10px solid var(--rewind-white);
|
||||
box-shadow:
|
||||
0 2px 4px rgb(0 0 0 / 0.4),
|
||||
-1px 1px 4px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
&.rank-1 {
|
||||
&::before {
|
||||
border-left: 10px solid #ffd82a;
|
||||
border-bottom: 10px solid #ffd82a;
|
||||
}
|
||||
}
|
||||
|
||||
&.rank-2 {
|
||||
&::before {
|
||||
border-left: 10px solid #d6d6d6;
|
||||
border-bottom: 10px solid #d6d6d6;
|
||||
}
|
||||
}
|
||||
|
||||
&.rank-3 {
|
||||
&::before {
|
||||
border-left: 10px solid #cd7f32;
|
||||
border-bottom: 10px solid #cd7f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.best-posts__post {
|
||||
width: 100%;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.best-posts__post p {
|
||||
font-family: var(--pixel-text) !important;
|
||||
font-weight: normal;
|
||||
font-size: var(--font-down-2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--rewind-grey);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
|
||||
.best-posts__post h1,
|
||||
.best-posts__post h2 {
|
||||
font-family: var(--jersey-heading);
|
||||
font-size: 32px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.best-post__post h5 {
|
||||
font-family: var(--jersey-heading);
|
||||
}
|
||||
|
||||
.best-posts__post table {
|
||||
font-family: var(--pixel-text) !important;
|
||||
font-weight: normal;
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
|
||||
.best-posts__post img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.best-posts__post code,
|
||||
.best-posts__post pre {
|
||||
font-family: var(--pixel-text);
|
||||
font-weight: normal;
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
|
||||
.best-posts__metadata a {
|
||||
font-family: var(--pixel-text);
|
||||
text-transform: uppercase;
|
||||
color: var(--rewind-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.best-posts__metadata {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--primary-700);
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.best-posts__likes,
|
||||
.best-posts__replies {
|
||||
font-family: var(--pixel-text);
|
||||
border: 1px solid var(--rewind-light-grey);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--rewind-border-radius);
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
color: var(--primary-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
.--best-topics {
|
||||
padding-bottom: 0;
|
||||
margin-top: 2em;
|
||||
|
||||
.rewind-report-container {
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.best-topics__title {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
gap: 1em;
|
||||
max-width: 700px;
|
||||
background: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 33%);
|
||||
align-items: start;
|
||||
|
||||
@media screen and (width <= 580px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.best-topics__header {
|
||||
font-family: var(--jersey-heading);
|
||||
margin: 0;
|
||||
color: var(--rewind-black);
|
||||
font-size: var(--font-up-1);
|
||||
line-height: var(--font-0);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.best-topics__metadata a {
|
||||
font-family: var(--pixel-text);
|
||||
text-transform: uppercase;
|
||||
color: var(--rewind-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.best-topics__metadata {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--primary-700);
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
padding-top: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.best-topics__topic {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
position: relative;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
max-width: 700px;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
225deg,
|
||||
transparent 13px,
|
||||
var(--rewind-white) 13px
|
||||
);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
border: 10px solid transparent;
|
||||
border-left: 10px solid var(--rewind-white);
|
||||
border-bottom: 10px solid var(--rewind-white);
|
||||
box-shadow:
|
||||
0 2px 4px rgb(0 0 0 / 0.4),
|
||||
-1px 1px 4px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
&.rank-1 {
|
||||
&::before {
|
||||
border-left: 10px solid #ffd82a;
|
||||
border-bottom: 10px solid #ffd82a;
|
||||
}
|
||||
}
|
||||
|
||||
&.rank-2 {
|
||||
&::before {
|
||||
border-left: 10px solid #d6d6d6;
|
||||
border-bottom: 10px solid #d6d6d6;
|
||||
}
|
||||
}
|
||||
|
||||
&.rank-3 {
|
||||
&::before {
|
||||
border-left: 10px solid #cd7f32;
|
||||
border-bottom: 10px solid #cd7f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.best-topics__excerpt {
|
||||
font-family: var(--pixel-text);
|
||||
font-size: var(--font-down-2);
|
||||
font-weight: 400;
|
||||
color: var(--rewind-grey);
|
||||
text-align: left;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
line-clamp: 8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.rewind-report-container {
|
||||
perspective: 1000px;
|
||||
|
||||
--ambientAmount: 0.1;
|
||||
--easeAmount: 0;
|
||||
animation: ambientMovement;
|
||||
animation-duration: 24s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
border-radius: var(--rewind-border-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
|
||||
&__title {
|
||||
font-family: var(--jersey-heading) !important;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-up-3);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@media screen and (width <= 525px) {
|
||||
font-size: var(--font-up-2);
|
||||
}
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
font-size: var(--font-up-0);
|
||||
}
|
||||
}
|
||||
|
||||
&__data {
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
.chat-usage {
|
||||
margin-inline: 2em;
|
||||
}
|
||||
|
||||
.--chat-usage {
|
||||
box-shadow:
|
||||
inset -1px -1px #0a0a0a,
|
||||
inset 1px 1px #dfdfdf,
|
||||
inset -2px -2px var(--rewind-grey),
|
||||
inset 2px 2px var(--rewind-white);
|
||||
padding: 2px;
|
||||
max-width: 660px;
|
||||
transform: rotate(0.25deg);
|
||||
margin-bottom: 2em;
|
||||
|
||||
.rewind-report-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
background: var(--rewind-light-grey);
|
||||
}
|
||||
|
||||
.chat-window__header {
|
||||
background: var(--rewind-blue);
|
||||
color: var(--rewind-white);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-window__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-window__status {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-window__messages {
|
||||
padding: 1em 0.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 100%;
|
||||
|
||||
&.--left {
|
||||
flex-direction: row;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&.--right {
|
||||
align-self: flex-end;
|
||||
justify-content: end;
|
||||
|
||||
.chat-message__author {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-message__bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
box-shadow:
|
||||
inset -1px -1px #0a0a0a,
|
||||
inset 1px 1px #dfdfdf,
|
||||
inset -2px -2px var(--rewind-grey),
|
||||
inset 2px 2px var(--rewind-white);
|
||||
|
||||
.chat-message.--left & {
|
||||
background: var(--rewind-grey);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-message.--right & {
|
||||
background: var(--rewind-magenta);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message__author {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
color: var(--rewind-grey);
|
||||
}
|
||||
|
||||
.chat-message__text {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--rewind-black);
|
||||
|
||||
strong {
|
||||
color: var(--rewind-blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message__gif {
|
||||
max-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-message__channels {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-channel-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--rewind-blue);
|
||||
gap: 0.5em;
|
||||
font-size: var(--font-down-2);
|
||||
|
||||
&:hover {
|
||||
background: var(--rewind-white);
|
||||
border-color: var(--rewind-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-channel-link__name {
|
||||
font-weight: bold;
|
||||
color: var(--rewind-blue);
|
||||
}
|
||||
|
||||
.chat-channel-link__count {
|
||||
font-size: 12px;
|
||||
color: var(--rewind-blue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.--fbff {
|
||||
--border-size: 6px;
|
||||
margin-block: 1em;
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
--border-size: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
--border-size: 3px;
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border: none;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
border: none;
|
||||
|
||||
@media screen and (width <= 625px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: min-content min-content;
|
||||
grid-template-areas: "gif gif gif" "friend nothing you";
|
||||
}
|
||||
}
|
||||
|
||||
.fbff-gif-container {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
border-radius: var(--rewind-border-radius);
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
z-index: 10;
|
||||
|
||||
@media screen and (width <= 625px) {
|
||||
grid-area: gif;
|
||||
}
|
||||
}
|
||||
|
||||
.fbff-gif {
|
||||
width: 100%;
|
||||
border-radius: var(--rewind-border-radius);
|
||||
}
|
||||
|
||||
.fbff-avatar-container {
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr min-content;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (width <= 625px) {
|
||||
&:first-of-type {
|
||||
grid-area: friend;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
grid-area: you;
|
||||
}
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
border-radius: 0;
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fbff-avatar-name {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
height: min-content;
|
||||
font-size: var(--font-down-1);
|
||||
font-family: "Pixelify Sans", sans-serif;
|
||||
color: var(--rewind-magenta);
|
||||
margin-top: 0.5em;
|
||||
line-height: 1;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
font-size: var(--font-down-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@use "lib/viewport";
|
||||
|
||||
.--most-viewed-tags,
|
||||
.--most-viewed-categories {
|
||||
.rewind-card {
|
||||
@include rewind-border;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: var(--rewind-black);
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
transition: transform 0.3s ease;
|
||||
transform: skew(-1deg) scaleY(1);
|
||||
|
||||
p {
|
||||
color: var(--rewind-white);
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-report-container {
|
||||
@include viewport.until(sm) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
|
||||
.folder-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-tab {
|
||||
display: block;
|
||||
content: "";
|
||||
width: 3em;
|
||||
height: 1em;
|
||||
border: 2px solid var(--rewind-green);
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
position: absolute;
|
||||
top: -1em;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
transition: background-color 0.3s ease;
|
||||
background: var(--rewind-black);
|
||||
}
|
||||
|
||||
.folder-bg {
|
||||
content: "";
|
||||
border: 2px solid var(--rewind-green);
|
||||
width: calc(100% - 4px);
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
border-radius: 0 4px 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.folder-wrapper {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin-top: 1.5em;
|
||||
width: 25%;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.rewind-card {
|
||||
padding: 0.5em 0.5em;
|
||||
}
|
||||
|
||||
@include viewport.until(sm) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&.--opened {
|
||||
z-index: 4;
|
||||
|
||||
.folder-bg,
|
||||
.folder-tab {
|
||||
background-color: var(--rewind-white);
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
transform: skew(-10deg) scaleY(0.9) translateY(7px) translateX(10px);
|
||||
box-shadow: -12px -30px 30px rgb(255 255 255 / 0.5);
|
||||
|
||||
p {
|
||||
color: var(--rewind-yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@font-face {
|
||||
font-family: receipt-narrow;
|
||||
src: absolute-image-url(
|
||||
"/plugins/discourse-rewind/fonts/receipt-narrow.woff2"
|
||||
)
|
||||
format("woff2");
|
||||
font-display: auto;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Jersey 20";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: absolute-image-url(
|
||||
"/plugins/discourse-rewind/fonts/jersey-20-v2-latin-regular.woff2"
|
||||
)
|
||||
format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: "Pixelify Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: absolute-image-url(
|
||||
"/plugins/discourse-rewind/fonts/pixelify-sans-v1-latin-regular.woff2"
|
||||
)
|
||||
format("woff2");
|
||||
}
|
||||
|
||||
.rewind {
|
||||
--jersey-heading: "Jersey 20", helvetica, arial, sans-serif;
|
||||
--pixel-text: "receipt-narrow", sans-serif;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
font-family: var(--jersey-heading);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
p,
|
||||
span,
|
||||
table,
|
||||
td,
|
||||
tr {
|
||||
font-family: var(--pixel-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
h2.rewind-report-title {
|
||||
text-transform: uppercase;
|
||||
color: var(--rewind-green);
|
||||
z-index: 2;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.--invites {
|
||||
margin-bottom: 10em;
|
||||
|
||||
.guest-book {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
background: #c0c0c0;
|
||||
border: 4px outset #dfdfdf;
|
||||
box-shadow: 5px 5px 0 rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.guest-book__cover {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f0f 0%, #0ff 100%);
|
||||
border-bottom: 4px ridge #fff;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgb(255 255 255 / 0.1) 10px,
|
||||
rgb(255 255 255 / 0.1) 20px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-book__title {
|
||||
font-family: "Comic Sans MS", "Comic Sans", cursive;
|
||||
font-size: 48px;
|
||||
color: #ff0;
|
||||
margin-bottom: 0.25em;
|
||||
text-shadow:
|
||||
3px 3px 0 #f0f,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
1px 1px 0 #000;
|
||||
font-weight: bold;
|
||||
animation: rainbow-text 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rainbow-text {
|
||||
0% {
|
||||
color: #ff0;
|
||||
}
|
||||
|
||||
16% {
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
33% {
|
||||
color: #0ff;
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #f0f;
|
||||
}
|
||||
|
||||
66% {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
83% {
|
||||
color: #ff0;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: #0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-book__subtitle {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
letter-spacing: 3px;
|
||||
text-shadow: 2px 2px 0 #000;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.guest-book__page {
|
||||
padding: 2em;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.guest-book__entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
padding: 0.75em;
|
||||
background: linear-gradient(to right, #ffd700 0%, #ff69b4 100%);
|
||||
border: 3px ridge #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
|
||||
&.--small {
|
||||
background: linear-gradient(to right, #87ceeb 0%, #98fb98 100%);
|
||||
font-size: 13px;
|
||||
padding: 0.5em 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-book__entry-label {
|
||||
color: #000;
|
||||
text-shadow: 1px 1px 0 rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.guest-book__entry-value {
|
||||
color: #000;
|
||||
font-size: 1.2em;
|
||||
background: #fff;
|
||||
padding: 0.25em 0.5em;
|
||||
border: 2px inset #c0c0c0;
|
||||
|
||||
.number {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-book__divider {
|
||||
height: 3px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
#f00 0,
|
||||
#f00 10px,
|
||||
#ff0 10px,
|
||||
#ff0 20px,
|
||||
#0f0 20px,
|
||||
#0f0 30px,
|
||||
#0ff 30px,
|
||||
#0ff 40px,
|
||||
#00f 40px,
|
||||
#00f 50px,
|
||||
#f0f 50px,
|
||||
#f0f 60px
|
||||
);
|
||||
margin: 1.5em 0;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.guest-book__section-title {
|
||||
font-family: "Comic Sans MS", "Comic Sans", cursive;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #f0f;
|
||||
margin-bottom: 1em;
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 0 #0ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.guest-book__signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
background: #ff0;
|
||||
border: 4px double #000;
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px #f0f,
|
||||
inset 0 0 0 4px #0ff;
|
||||
|
||||
&:hover {
|
||||
background: #ff9;
|
||||
animation: blink 0.5s infinite;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
border: 3px solid #f0f;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
background: #ff0;
|
||||
}
|
||||
|
||||
50% {
|
||||
background: #f0f;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-book__signature-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.guest-book__signature-name {
|
||||
font-family: "Comic Sans MS", "Comic Sans", cursive;
|
||||
font-size: 22px;
|
||||
color: #f0f;
|
||||
font-weight: bold;
|
||||
text-shadow: 1px 1px 0 #0ff;
|
||||
}
|
||||
|
||||
.guest-book__signature-realname {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.--most-viewed-categories {
|
||||
.rewind-report-container {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
@include rewind-border;
|
||||
width: max-content;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.most-viewed-categories__category {
|
||||
font-family: var(--pixel-text);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.--most-viewed-tags {
|
||||
.rewind-report-title {
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.rewind-report-container {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.most-viewed-tags__tag {
|
||||
font-family: var(--pixel-text);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@use "lib/viewport";
|
||||
|
||||
.--new-user-interactions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1em;
|
||||
|
||||
.wordart-container {
|
||||
text-align: center;
|
||||
margin: 2em 0 2.5em;
|
||||
}
|
||||
|
||||
.wordart-text {
|
||||
font-size: 32px;
|
||||
color: var(--rewind-white);
|
||||
font-family: "Comic Sans MS", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.wordart-3d {
|
||||
font-size: 64px;
|
||||
font-weight: 900;
|
||||
font-family: Impact, Arial, sans-serif;
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
transform: rotate(-3deg);
|
||||
line-height: 1;
|
||||
|
||||
@include viewport.until(sm) {
|
||||
font-size: 24px;
|
||||
transform: translateY(1.5em);
|
||||
}
|
||||
}
|
||||
|
||||
.wordart-word {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.75em;
|
||||
}
|
||||
|
||||
.wordart-letter {
|
||||
font-family: impact, arial, sans-serif;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
color: #0ff;
|
||||
letter-spacing: -0.4em;
|
||||
text-shadow:
|
||||
1px 1px 0 #00d,
|
||||
2px 2px 0 #00d,
|
||||
3px 3px 0 #00b,
|
||||
4px 4px 0 #00b,
|
||||
5px 5px 0 #009,
|
||||
6px 6px 0 #009,
|
||||
7px 7px 0 #007,
|
||||
8px 8px 0 #007,
|
||||
9px 9px 0 #005,
|
||||
10px 10px 0 #005,
|
||||
11px 11px 0 #003,
|
||||
12px 12px 0 #003,
|
||||
13px 13px 0 #001,
|
||||
14px 14px 0 #001,
|
||||
15px 15px 20px rgb(0 0 0 / 0.5);
|
||||
animation: wordart-wave 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Wave pattern using nth-child
|
||||
@for $i from 1 through 30 {
|
||||
.wordart-letter:nth-child(#{$i}) {
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
transform: translateY(#{sin($i * 0.5) * 25}px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordart-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-35px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.--post-received-reactions {
|
||||
margin-block: 1em;
|
||||
|
||||
.rewind-report-container {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5em;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
gap: 0.125em;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 0 0 0 -1em;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
height: min-content;
|
||||
perspective: 1000px;
|
||||
will-change: transform;
|
||||
transform-style: preserve-3d;
|
||||
border-radius: var(--rewind-border-radius);
|
||||
cursor: pointer;
|
||||
animation-name: ambientMovement;
|
||||
animation-duration: 4000ms;
|
||||
animation-delay: calc(-4000ms * var(--rand));
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
z-index: 1;
|
||||
color: var(--rewind-white);
|
||||
border: none;
|
||||
|
||||
.emoji {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
image-rendering: crisp-edges;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card__emoji {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scale:nth-child(1) {
|
||||
padding: 0.25em;
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 12deg));
|
||||
|
||||
.emoji {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.scale:nth-child(2) {
|
||||
padding: 0.35em;
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 11.2deg));
|
||||
|
||||
.emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.scale:nth-child(3) {
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 10deg));
|
||||
padding: 0.5em;
|
||||
|
||||
.emoji {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.scale:nth-child(4) {
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 8deg));
|
||||
padding: 0.75em;
|
||||
|
||||
.emoji {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.scale:nth-child(5) {
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 13deg));
|
||||
}
|
||||
|
||||
@media screen and (width <= 625px) {
|
||||
.scale {
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.scale:nth-child(1) {
|
||||
padding: 0.15em;
|
||||
}
|
||||
|
||||
.scale:nth-child(2) {
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
.scale:nth-child(3) {
|
||||
padding: 0.35em;
|
||||
}
|
||||
|
||||
.scale:nth-child(4) {
|
||||
padding: 0.45em;
|
||||
}
|
||||
|
||||
.scale:nth-child(5) {
|
||||
padding: 0.65em;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
.scale {
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.scale:nth-child(1) {
|
||||
padding: 0.15em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scale:nth-child(2) {
|
||||
padding: 0.2em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.scale:nth-child(3) {
|
||||
padding: 0.2em;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.scale:nth-child(4) {
|
||||
padding: 0.2em;
|
||||
}
|
||||
|
||||
.scale:nth-child(5) {
|
||||
padding: 0.2em;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.--post-used-reactions {
|
||||
.rewind-card {
|
||||
@include rewind-border;
|
||||
border-radius: 0 var(--rewind-border-radius) var(--rewind-border-radius)
|
||||
var(--rewind-border-radius);
|
||||
}
|
||||
|
||||
.rewind-reactions-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rewind-reactions-row {
|
||||
display: grid;
|
||||
grid-template-columns: 25px 50px 1fr;
|
||||
gap: 1em;
|
||||
padding: 1em 0;
|
||||
|
||||
img.emoji {
|
||||
vertical-align: baseline;
|
||||
image-rendering: crisp-edges;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
.rewind-reactions-bar {
|
||||
animation-delay: 0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
.rewind-reactions-bar {
|
||||
animation-delay: -0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
.rewind-reactions-bar {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
.rewind-reactions-bar {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-weight: normal;
|
||||
font-display: var(--pixel-text);
|
||||
font-size: 16px;
|
||||
color: var(--rewind-white);
|
||||
padding-top: 0.35em;
|
||||
}
|
||||
|
||||
.rewind-reactions-bar {
|
||||
background: var(--rewind-white);
|
||||
height: 25px;
|
||||
background-size: 50px 50px;
|
||||
border-radius: 3px;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
var(--rewind-black) 12.5%,
|
||||
var(--rewind-white) 12.5%,
|
||||
var(--rewind-white) 50%,
|
||||
var(--rewind-black) 50%,
|
||||
var(--rewind-black) 62.5%,
|
||||
var(--rewind-white) 62.5%
|
||||
);
|
||||
border: 4px solid var(--rewind-black);
|
||||
box-shadow: 0 0 0 4px var(--rewind-white);
|
||||
animation: anim 5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes anim {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 25px 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-total-reactions {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin-top: 1em;
|
||||
color: var(--rewind-green);
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
.reading-time__book {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.book {
|
||||
width: 150px;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(-20deg);
|
||||
z-index: 2;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 100px;
|
||||
height: 133px;
|
||||
}
|
||||
}
|
||||
|
||||
p.reading-time__text {
|
||||
font-family: var(--pixel-text);
|
||||
}
|
||||
|
||||
.reading-time code {
|
||||
background-color: var(--rewind-white);
|
||||
color: var(--rewind-black);
|
||||
}
|
||||
|
||||
.book img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 150px;
|
||||
height: 200px;
|
||||
transform: translateZ(17.5px);
|
||||
background-color: #01060f;
|
||||
border-radius: 0 5px 5px 0;
|
||||
box-shadow: 5px 5px 20px rgb(0 0 0 / 0.05);
|
||||
image-rendering: crisp-edges;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 100px;
|
||||
height: 133px;
|
||||
}
|
||||
}
|
||||
|
||||
.book::before {
|
||||
position: absolute;
|
||||
content: " ";
|
||||
left: 0;
|
||||
top: 4px;
|
||||
width: 33px;
|
||||
height: 192px;
|
||||
transform: translateX(128.5px) rotateY(90deg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fff 0%,
|
||||
#f9f9f9 5%,
|
||||
#fff 10%,
|
||||
#f9f9f9 15%,
|
||||
#fff 20%,
|
||||
#f9f9f9 25%,
|
||||
#fff 30%,
|
||||
#f9f9f9 35%,
|
||||
#fff 40%,
|
||||
#f9f9f9 45%,
|
||||
#fff 50%,
|
||||
#f9f9f9 55%,
|
||||
#fff 60%,
|
||||
#f9f9f9 65%,
|
||||
#fff 70%,
|
||||
#f9f9f9 75%,
|
||||
#fff 80%,
|
||||
#f9f9f9 85%,
|
||||
#fff 90%,
|
||||
#b6b6b6 95%,
|
||||
#b6b6b6 100%
|
||||
);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
left: -43px;
|
||||
width: 22px;
|
||||
height: 125px;
|
||||
}
|
||||
}
|
||||
|
||||
.book::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: " ";
|
||||
width: 150px;
|
||||
height: 200px;
|
||||
transform: translateZ(-17.5px);
|
||||
background-color: var(--book-color);
|
||||
border-radius: 0 5px 5px 0;
|
||||
box-shadow: 0 0 1px 1px rgb(0, 0, 0, 0.25);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 100px;
|
||||
height: 133px;
|
||||
}
|
||||
}
|
||||
|
||||
.reading-time__text {
|
||||
color: var(--rewind-white);
|
||||
font-weight: normal;
|
||||
width: 50%;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
|
||||
.reading-time .rewind-card {
|
||||
gap: 1em;
|
||||
padding: 1.5em;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
|
||||
--book-color: var(--primary-very-high);
|
||||
|
||||
@include rewind-border;
|
||||
border-radius: 0 var(--rewind-border-radius) var(--rewind-border-radius)
|
||||
var(--rewind-border-radius);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
padding: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.book-series {
|
||||
width: 150px;
|
||||
height: 200px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(-20deg);
|
||||
position: absolute;
|
||||
background-color: var(--book-color);
|
||||
border-radius: 0 5px 5px 0;
|
||||
box-shadow: 0 0 1px 1px rgb(0, 0, 0, 0.25);
|
||||
z-index: 1;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 100px;
|
||||
height: 133px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
content: " ";
|
||||
left: 0;
|
||||
top: 4px;
|
||||
width: 33px;
|
||||
height: 192px;
|
||||
transform: translateX(128.5px) rotateY(90deg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fff 0%,
|
||||
#f9f9f9 5%,
|
||||
#fff 10%,
|
||||
#f9f9f9 15%,
|
||||
#fff 20%,
|
||||
#f9f9f9 25%,
|
||||
#fff 30%,
|
||||
#f9f9f9 35%,
|
||||
#fff 40%,
|
||||
#f9f9f9 45%,
|
||||
#fff 50%,
|
||||
#f9f9f9 55%,
|
||||
#fff 60%,
|
||||
#f9f9f9 65%,
|
||||
#fff 70%,
|
||||
#f9f9f9 75%,
|
||||
#fff 80%,
|
||||
#f9f9f9 85%,
|
||||
#fff 90%,
|
||||
#b6b6b6 95%,
|
||||
#b6b6b6 100%
|
||||
);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
left: -43px;
|
||||
width: 22px;
|
||||
height: 125px;
|
||||
}
|
||||
}
|
||||
|
||||
&.one {
|
||||
top: 3px;
|
||||
left: 14px;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
top: 3px;
|
||||
left: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&.two {
|
||||
top: 5px;
|
||||
left: 22px;
|
||||
z-index: -1;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
top: 5px;
|
||||
left: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
&.three {
|
||||
top: 7px;
|
||||
left: 31px;
|
||||
z-index: -2;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.rewind-report-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--font-up-2);
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
flex-direction: column;
|
||||
|
||||
@media (width <= 768px) {
|
||||
padding: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rewind-report-container {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
align-self: start;
|
||||
padding: 0.25em 1em 0.25em;
|
||||
margin-bottom: -2px;
|
||||
background: var(--rewind-black);
|
||||
margin-left: 0;
|
||||
position: relative;
|
||||
font-size: var(--font-down-1);
|
||||
text-align: left;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-top: 2px solid var(--rewind-green);
|
||||
border-left: 2px solid var(--rewind-green);
|
||||
border-right: 2px solid var(--rewind-green);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.quick-access-panel {
|
||||
> ul,
|
||||
.empty-state__container {
|
||||
height: 100%; // flex alignment fix
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-callout__container {
|
||||
height: 47px;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
|
||||
@media screen and (width <= 639px) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
#rewind-vhs {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn.rewind-callout {
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: transform 0.3s ease;
|
||||
transform-origin: left top;
|
||||
z-index: 2;
|
||||
|
||||
svg {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(10px) translateY(10px) scale(1.05);
|
||||
|
||||
svg {
|
||||
box-shadow: -10px 10px 15px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-callout {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transition: transform 200ms ease;
|
||||
|
||||
.btn.no-text.--special-kbd {
|
||||
top: 0.75em;
|
||||
right: 1em;
|
||||
font-size: var(--font-down-3);
|
||||
|
||||
@media screen and (width <= 639px) {
|
||||
top: 0.45em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-callout::before {
|
||||
content: "";
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 287.46 22.28"><path d="m0 0 64.78 22.28h44.53L44.54 0z" style="fill:%23d0232b"/><path d="m44.54 0 64.77 22.28h44.54L89.08 0z" style="fill:%23f15e24"/><path d="m89.08 0 64.77 22.28h44.54L133.61 0z" style="fill:%23fdf6b0"/><path d="m133.61 0 64.78 22.28h44.54L178.15 0z" style="fill:%230ea94e"/><path d="m178.15 0 64.78 22.28h44.53L222.69 0z" style="fill:%232bace2"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: 47% 100%;
|
||||
background-size: 139px;
|
||||
background-blend-mode: multiply;
|
||||
position: absolute;
|
||||
left: -48px;
|
||||
top: -15px;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
background-color: #d4d1bc;
|
||||
transform: skew(72deg, 0deg);
|
||||
transform-origin: 50% 0% 0;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
width 200ms ease;
|
||||
}
|
||||
|
||||
.rewind-callout::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: -5px;
|
||||
z-index: -1;
|
||||
width: 10px;
|
||||
height: calc(100% - 3px);
|
||||
background-color: #e8e5d0;
|
||||
transform: skew(0deg, 41deg) scale3d(1, 1, 1);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.rewind-logo {
|
||||
height: 40px;
|
||||
|
||||
&.--light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.--dark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&.--light {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.--dark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@if is-dark-color-scheme() {
|
||||
&.--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.--light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind__header {
|
||||
padding: 1em 0 1em 1em;
|
||||
background-color: #fbf8e4;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168.7 52.42"><path d="m0 0 49.67 52.42h23.8L23.81 0z" style="fill:%23d0222b"/><path d="m23.81 0 49.66 52.42h23.81L47.62 0z" style="fill:%23f15f25"/><path d="m47.62 0 49.66 52.42h23.81L71.42 0z" style="fill:%23fcf6b2"/><path d="m71.42 0 49.67 52.42h23.8L95.23 0z" style="fill:%2310a94d"/><path d="m95.23 0 49.66 52.42h23.81L119.04 0z" style="fill:%2329abe2"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: 150px;
|
||||
top: 0;
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
color: var(--rewind-black);
|
||||
|
||||
.rewind-logo {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0.5em 0 0;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-up-1);
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: grid;
|
||||
grid-template-areas: "logo rewind" "logo year";
|
||||
width: 7em;
|
||||
|
||||
svg {
|
||||
grid-area: logo;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
grid-area: rewind;
|
||||
line-height: 1;
|
||||
transform: skewX(15deg);
|
||||
}
|
||||
|
||||
&-year {
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-notification-active #toggle-current-user::after {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
content: "";
|
||||
background-image: absolute-image-url(
|
||||
"/plugins/discourse-rewind/images/rewind-avatar-2-shimmer.gif"
|
||||
);
|
||||
display: block;
|
||||
background-size: cover;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
.rewind-container {
|
||||
border-radius: var(--d-border-radius);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&:not(.--fullscreen) {
|
||||
height: 65vh;
|
||||
}
|
||||
|
||||
&.--fullscreen {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
width: calc(100vw - (100vw - 100%) + 2px);
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
background: rgb(var(--secondary-rgb), 0.5);
|
||||
backdrop-filter: blur(4.9px);
|
||||
padding: 2em;
|
||||
|
||||
.rewind {
|
||||
max-width: 1020px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
border-radius: 30px;
|
||||
container-type: size;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@media (width <= 768px) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
z-index: 3;
|
||||
background: rgb(0 0 0 / 0.3);
|
||||
opacity: 0.75;
|
||||
animation: scanline 6s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 50%,
|
||||
rgb(0 0 0 / 0.1) 51%
|
||||
);
|
||||
background-size: 100% 4px;
|
||||
animation: scanlines 1s steps(60) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
transform: translate3d(0, 200000%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanlines {
|
||||
0% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.rewind__scroll-wrapper {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--rewind-black);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
// specific to override core
|
||||
.btn.no-text.--special-kbd {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 24px;
|
||||
background-color: #ede9d4;
|
||||
border-color: #d7d1b0 #c5bfa0 #d7d1b0 #c5bfa0;
|
||||
border-width: 1px 5px 7px 4px;
|
||||
box-shadow: -1px 1px 0 1px #c0b99b;
|
||||
z-index: 3;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.1s ease;
|
||||
transform: skew(3deg);
|
||||
font-size: var(--font-down-1);
|
||||
|
||||
.d-icon {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.discourse-no-touch & {
|
||||
&:hover {
|
||||
filter: brightness(0.95);
|
||||
|
||||
.d-icon {
|
||||
color: var(--rewind-grey);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: none;
|
||||
transform: skew(3deg) translateX(-1px) translateY(1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind__prev-btn {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rewind__next-btn {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rewind-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100cqh;
|
||||
gap: 1em;
|
||||
box-sizing: border-box;
|
||||
background: var(--rewind-black);
|
||||
|
||||
&__text {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-up-2);
|
||||
color: var(--rewind-green);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border-color: var(--rewind-white);
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
@import "variables";
|
||||
|
||||
.time-of-day__oscilloscope {
|
||||
background: var(--rewind-black);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgb(0, 255, 0, 0.02) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.--glitching {
|
||||
svg {
|
||||
animation: peak-glitch 0.2s step-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oscilloscope__play-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
background: rgb(0, 0, 0, 0.7);
|
||||
border: 1px solid var(--rewind-green);
|
||||
color: var(--rewind-green);
|
||||
padding: 0.5em;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.discourse-no-touch & {
|
||||
&:hover {
|
||||
color: var(--rewind-white);
|
||||
background: rgb(0, 255, 0, 0.2) !important;
|
||||
box-shadow: 0 0 10px var(--rewind-green);
|
||||
|
||||
.d-icon {
|
||||
color: var(--rewind-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.--playing {
|
||||
background: rgb(0, 255, 0, 0.3);
|
||||
animation: audio-pulse 0.5s ease-in-out infinite;
|
||||
|
||||
.d-icon {
|
||||
color: var(--rewind-magenta);
|
||||
}
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--rewind-green);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes audio-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--rewind-green);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 10px var(--rewind-green);
|
||||
}
|
||||
}
|
||||
|
||||
.oscilloscope__svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oscilloscope__grid-line {
|
||||
stroke: var(--rewind-green);
|
||||
stroke-width: 0.5;
|
||||
opacity: 0.2;
|
||||
|
||||
&.--vertical {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
.oscilloscope__time-label {
|
||||
fill: var(--rewind-green);
|
||||
font-size: 16px;
|
||||
text-anchor: middle;
|
||||
font-family: monospace;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oscilloscope__waveform {
|
||||
fill: none;
|
||||
stroke: var(--rewind-green);
|
||||
stroke-width: 2;
|
||||
filter: url("#glow");
|
||||
animation: pulse-waveform 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.oscilloscope__dot {
|
||||
fill: var(--rewind-green);
|
||||
filter: url("#glow");
|
||||
opacity: 0.8;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
|
||||
&.active {
|
||||
fill: var(--rewind-yellow);
|
||||
filter: url("#glow-strong");
|
||||
opacity: 1;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.oscilloscope__playback-dot {
|
||||
fill: var(--rewind-magenta);
|
||||
filter: url("#glow-strong");
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-waveform {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes peak-glitch {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(-10px, 5px);
|
||||
filter: blur(2px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(12px, -8px);
|
||||
filter: blur(1px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-8px, 3px);
|
||||
filter: blur(3px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(15px, -5px);
|
||||
filter: blur(0.5px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-12px, 7px);
|
||||
filter: blur(2px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(10px, -4px);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(-6px, 6px);
|
||||
filter: blur(1px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(8px, -3px);
|
||||
filter: blur(0.5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(-4px, 2px);
|
||||
filter: blur(0.3px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.--time-of-day-activity {
|
||||
padding: 0;
|
||||
|
||||
@media screen and (width >= 500px) {
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
.--top-words .rewind-report-container {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1em;
|
||||
|
||||
.rewind-report-title {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-left: 0;
|
||||
border: none !important;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.cards-container {
|
||||
display: flex;
|
||||
|
||||
@media (width <= 525px) {
|
||||
display: grid;
|
||||
grid-template:
|
||||
". . . . w w . . . . " auto
|
||||
". . . . w w . . . . " auto
|
||||
". . a a w w z z . . " auto
|
||||
". . a a w w z z . . " auto
|
||||
". . a a . . z z . . " auto
|
||||
". . a a . . z z . . " auto
|
||||
". . . d d c c . . . " auto
|
||||
". . . d d c c . . . " auto
|
||||
". . . d d c c . . . " auto
|
||||
". . . d d c c . . . " auto
|
||||
/ auto auto auto auto auto auto auto auto;
|
||||
|
||||
.rewind-card__wrapper:nth-child(1) {
|
||||
grid-area: w;
|
||||
}
|
||||
|
||||
.rewind-card__wrapper:nth-child(2) {
|
||||
grid-area: a;
|
||||
}
|
||||
|
||||
.rewind-card__wrapper:nth-child(3) {
|
||||
grid-area: z;
|
||||
}
|
||||
|
||||
.rewind-card__wrapper:nth-child(4) {
|
||||
grid-area: d;
|
||||
}
|
||||
|
||||
.rewind-card__wrapper:nth-child(5) {
|
||||
grid-area: c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card__wrapper {
|
||||
width: 150px;
|
||||
height: 175px;
|
||||
perspective: 1000px;
|
||||
will-change: transform;
|
||||
transform-style: preserve-3d;
|
||||
border-radius: var(--rewind-border-radius);
|
||||
cursor: pointer;
|
||||
animation-name: ambientMovement;
|
||||
animation-duration: 4000ms;
|
||||
animation-delay: calc(-4000ms * var(--rand));
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
transform: rotateZ(calc(var(--ambientAmount) * 10deg));
|
||||
z-index: 1;
|
||||
|
||||
@media screen and (width <= 625px) {
|
||||
width: 115px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
width: 100px;
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 350px) {
|
||||
width: 80px;
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
.rewind-card {
|
||||
box-shadow: 0 0 0 4px rgb(var(--mystery-color-light), 1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rewind-card__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform, box-shadow;
|
||||
transition-duration: 0.35s;
|
||||
transition-timing-function: cubic-bezier(0.73, 0.42, 0.03, 0.78);
|
||||
box-shadow: 0 0 6px 2px rgb(var(--primary-rgb), 0.25);
|
||||
|
||||
@if is-dark-color-scheme() {
|
||||
box-shadow: 0 0 6px 2px rgb(var(--secondary-rgb), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.mouseleave {
|
||||
animation: flip-shrink 0.35s cubic-bezier(0.73, 0.42, 0.03, 0.78) 0s
|
||||
forwards;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
&.flipped .rewind-card__inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
animation: flip-zoom 0.35s cubic-bezier(0.73, 0.42, 0.03, 0.78) 0s forwards;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
&:hover .rewind-card__inner {
|
||||
box-shadow: 0 3px 8px 2px rgb(var(--primary-rgb), 0.25);
|
||||
|
||||
@if is-dark-color-scheme() {
|
||||
box-shadow: 0 3px 8px 2px rgb(var(--primary-rgb), 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card.--front {
|
||||
padding: 0.5em;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"tl . . " 1fr
|
||||
". . . " 1fr
|
||||
". cr . " 3fr
|
||||
". . . " 1fr
|
||||
". . br" 1fr
|
||||
/ 1fr 3fr 1fr;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 4px 4px,
|
||||
rgb(0 0 0 / 0.05) 1px,
|
||||
var(--rewind-white) 1px,
|
||||
var(--rewind-white) 6px
|
||||
)
|
||||
0 0 / 6px 6px,
|
||||
var(--rewind-white);
|
||||
}
|
||||
|
||||
.rewind-card.--back {
|
||||
transform: rotateY(180deg);
|
||||
background-color: var(--rewind-white);
|
||||
color: var(--rewind-black);
|
||||
}
|
||||
|
||||
.rewind-card.--front,
|
||||
.rewind-card.--back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%; /* Safari */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
&.--long-word .rewind-card__title {
|
||||
font-size: var(--font-0);
|
||||
}
|
||||
|
||||
&.--long-word .rewind-card {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.rewind-card__title {
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rewind-card__data {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.rewind-card__image {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.rewind-card__image.tl {
|
||||
grid-area: tl;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card__image.cr {
|
||||
grid-area: cr;
|
||||
|
||||
img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rewind-card__image.br {
|
||||
grid-area: br;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transform: rotate(180deg);
|
||||
|
||||
@media screen and (width <= 475px) {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flip-zoom {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flip-shrink {
|
||||
0% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientMovement {
|
||||
0% {
|
||||
--ambientAmount: 0.1;
|
||||
}
|
||||
|
||||
50% {
|
||||
--ambientAmount: -0.1;
|
||||
}
|
||||
|
||||
100% {
|
||||
--ambientAmount: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@property --ambientAmount {
|
||||
syntax: "<number>";
|
||||
inherits: true;
|
||||
initial-value: 0.1;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
:root {
|
||||
--d-yellow: 251, 245, 175;
|
||||
--d-blue: 40, 171, 226;
|
||||
--d-green: 12, 166, 78;
|
||||
--d-orange: 240, 121, 74;
|
||||
--d-red: 232, 74, 81;
|
||||
--d-yellow-dark: 197, 193, 140;
|
||||
--d-blue-dark: 39, 137, 178;
|
||||
--d-green-dark: 17, 138, 68;
|
||||
--d-orange-dark: 188, 105, 65;
|
||||
--d-red-dark: 183, 64, 70;
|
||||
--rewind-border-radius: 4px;
|
||||
--rewind-green: #0f0;
|
||||
--rewind-blue: #00f;
|
||||
--rewind-black: #111;
|
||||
--rewind-white: #fff;
|
||||
--rewind-magenta: #f0f;
|
||||
--rewind-grey: #555;
|
||||
--rewind-light-grey: #aaa;
|
||||
--rewind-yellow: rgb(237, 237, 84);
|
||||
--rewind-beige: #fbf8e4;
|
||||
}
|
||||
|
||||
@mixin rewind-border() {
|
||||
border: 2px solid var(--rewind-green);
|
||||
border-radius: var(--rewind-border-radius);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@use "lib/viewport";
|
||||
|
||||
.--writing-analysis {
|
||||
.writing-analysis {
|
||||
border: 2px solid var(--rewind-green);
|
||||
width: 100%;
|
||||
color: var(--rewind-green);
|
||||
font-size: 0.6em;
|
||||
font-family: "Pixelify Sans", sans-serif;
|
||||
}
|
||||
|
||||
.rewind-report-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.writing-analysis__titlebar {
|
||||
background: var(--rewind-green);
|
||||
color: #000;
|
||||
padding: 6px 10px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.writing-analysis__menubar {
|
||||
background: var(--rewind-green);
|
||||
color: #000;
|
||||
padding: 4px 10px 6px;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.writing-analysis__menu-item--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.writing-analysis__frame {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.writing-analysis__header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.writing-analysis__helpbox {
|
||||
border: 2px solid var(--rewind-green);
|
||||
margin-left: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
|
||||
@include viewport.until(sm) {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.writing-analysis__release {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.writing-analysis__release-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.writing-analysis__release-meta {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.writing-analysis__stats {
|
||||
border-top: 14px solid var(--rewind-green);
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
@include viewport.until(sm) {
|
||||
flex-direction: column;
|
||||
border-top: none;
|
||||
border-bottom: 14px solid var(--rewind-green);
|
||||
padding: 20px 0;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.writing-analysis__stats-col {
|
||||
padding-top: 10px;
|
||||
width: 140px;
|
||||
border-right: 10px solid var(--rewind-green);
|
||||
|
||||
&:last-of-type {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
@include viewport.until(sm) {
|
||||
border-right: none;
|
||||
border-top: 10px solid var(--rewind-green);
|
||||
padding: 10px 10px 0 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.writing-analysis__stats-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.writing-analysis__stats-value {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "activity-calendar";
|
||||
@@ -0,0 +1,9 @@
|
||||
.-activity-calendar {
|
||||
.rewind-calendar {
|
||||
border-spacing: 1px;
|
||||
}
|
||||
|
||||
.rewind-calendar-cell {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
en:
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
discourse_rewind: "Discourse Rewind"
|
||||
js:
|
||||
discourse_rewind:
|
||||
placeholder: Discourse Rewind
|
||||
title: Rewind
|
||||
loading: "Reticulating splines..."
|
||||
preferences:
|
||||
disable_rewind: "Disable Discourse Rewind. This hides the year-end activity summary in your profile and on your avatar."
|
||||
reports:
|
||||
activity_calendar:
|
||||
title: Activity Calendar
|
||||
cell_title:
|
||||
visited_no_posts: "You visited and lurked on %{date}, but made no posts."
|
||||
visited_with_posts:
|
||||
one: "You made %{count} post on %{date}."
|
||||
other: "You made %{count} posts on %{date}."
|
||||
no_activity: "You weren't around on %{date}."
|
||||
top_words:
|
||||
title: Word Usage
|
||||
reading_time:
|
||||
title: Reading time
|
||||
book_comparison: "You spent <code>%{readingTitme}</code> reading on our site! That's the time it would take to read through <i>%{bookTitle}</i>"
|
||||
post_used_reactions:
|
||||
title: Most used reactions in topics
|
||||
total_number: "Total number of reactions: %{count}"
|
||||
post_received_reactions:
|
||||
title: Most received reactions in topics
|
||||
fbff:
|
||||
title: Your FBFF (Forum Best Friend Forever)
|
||||
most_viewed_categories:
|
||||
title:
|
||||
one: Your most viewed category
|
||||
other: Your %{count} most viewed categories
|
||||
most_viewed_tags:
|
||||
title:
|
||||
one: Your most viewed tag
|
||||
other: Your %{count} most viewed tags
|
||||
best_topics:
|
||||
view_topic: View topic
|
||||
title:
|
||||
one: Your best topic
|
||||
other: Your %{count} best topics
|
||||
best_posts:
|
||||
view_post: View post
|
||||
title:
|
||||
one: Your best post
|
||||
other: Your %{count} best posts
|
||||
ai_usage:
|
||||
title: AI Usage
|
||||
total_requests: Total Requests
|
||||
total_tokens: Total Tokens
|
||||
success_rate: Success Rate
|
||||
favorite_features: Most Used Features
|
||||
favorite_models: Most Used AI Models
|
||||
wake_up: "Wake up, %{username}..."
|
||||
system_title: "AI USAGE DETECTED"
|
||||
section_features: "TOP FEATURES"
|
||||
section_models: "TOP MODELS"
|
||||
assignments:
|
||||
title: Assignments
|
||||
total_assigned: Assigned to me
|
||||
completed: Assignments completed
|
||||
pending: Assignments pending
|
||||
assigned_by_user: Assignments given
|
||||
completion_rate: Completion rate
|
||||
chat_usage:
|
||||
title: Chat Activity
|
||||
total_messages: Total Messages
|
||||
dm_messages: DM Messages
|
||||
unique_dm_channels: DM Conversations
|
||||
reactions_received: Reactions Received
|
||||
avg_message_length: Avg Message Length
|
||||
favorite_channels: Favorite Channels
|
||||
messages: messages
|
||||
bot_name: XxRewindBotxX
|
||||
you: You
|
||||
channel_title: "#chat-activity"
|
||||
status_online: "● online"
|
||||
message_1: "You sent <strong>%{count}</strong> messages this year!"
|
||||
reply_1: "lol awesome B-)"
|
||||
message_2: "You had <strong>%{dm_count}</strong> DM conversations with <strong>%{channel_count}</strong> different people"
|
||||
reply_2: "cOo0oL :D"
|
||||
message_3: "People loved your messages! You got <strong>%{count}</strong> reactions ❤️"
|
||||
reply_3: "r0fl rAwr 8-)"
|
||||
message_4: "Your average message length was <strong>%{length}</strong> characters"
|
||||
message_5: "Your favorite channels:"
|
||||
dancing_baby_alt: "Dancing baby"
|
||||
favorite_gifs:
|
||||
title:
|
||||
one: Your favorite GIF
|
||||
other: Your %{count} favorite GIFs
|
||||
total_usage:
|
||||
one: Used %{count} time
|
||||
other: Used %{count} times total
|
||||
invites:
|
||||
title: Invites
|
||||
total_invites: Total Invites Sent
|
||||
redeemed: Accepted Invites
|
||||
redemption_rate: Acceptance Rate
|
||||
avg_trust_level: Avg Trust Level
|
||||
invitee_impact: Impact of Your Invitees
|
||||
invitee_posts: Posts Created
|
||||
invitee_topics: Topics Started
|
||||
invitee_likes: Likes Given
|
||||
most_active_invitee: Most Active Invitee
|
||||
guest_book_title: Guest Book
|
||||
guest_book_subtitle: Your Community Invitations
|
||||
label_invitations_sent: "Invitations sent:"
|
||||
label_guests_joined: "Invites accepted:"
|
||||
label_acceptance_rate: "Acceptance rate:"
|
||||
section_contributions: Invitee Contributions
|
||||
label_posts_written: "Posts written:"
|
||||
label_topics_started: "Topics started:"
|
||||
label_likes_given: "Likes given:"
|
||||
section_most_active: Most Active Guest
|
||||
new_user_interactions:
|
||||
title: Mentoring New Users
|
||||
subtitle:
|
||||
one: You helped %{count} new user this year
|
||||
other: You helped %{count} new users this year
|
||||
new_member:
|
||||
one: "%{count} new member"
|
||||
other: "%{count} new members"
|
||||
total_interactions: Total Interactions
|
||||
unique_new_users: Unique New Users
|
||||
likes_given: Likes Given
|
||||
replies: Replies to Posts
|
||||
mentions: Mentions
|
||||
topics_with_new_users: Topics with New Users
|
||||
time_of_day_activity:
|
||||
title: Daily Activity Rhythm
|
||||
play_button: Play your activity as sound
|
||||
stop_button: Stop audio playback
|
||||
writing_analysis:
|
||||
title: Writing Analysis
|
||||
menu_file: File
|
||||
menu_other: Other
|
||||
menu_additional: Additional
|
||||
menu_opening: OPENING
|
||||
help_text: Press F1 for help
|
||||
app_name: DiscoStar Professional
|
||||
release_info: Release %{rewindYear} from CDCK
|
||||
total_words: Total Words
|
||||
total_posts: Total Posts
|
||||
avg_post_length: Avg Post Length
|
||||
readability_score_label: Readability Score
|
||||
readability_level: Readability Level
|
||||
readability_score:
|
||||
over_80:
|
||||
1: "Ernest Hemingway"
|
||||
2: "Raymond Carver"
|
||||
3: "Michael Crichton"
|
||||
4: "Roald Dahl"
|
||||
over_60:
|
||||
1: "C.S. Lewis"
|
||||
2: "Stephen King"
|
||||
3: "Agatha Christie"
|
||||
4: "George R.R. Martin"
|
||||
over_40:
|
||||
1: "George Orwell"
|
||||
2: "F. Scott Fitzgerald"
|
||||
3: "Aldous Huxley"
|
||||
4: "Joan Didion"
|
||||
over_20:
|
||||
1: "Cormac McCarthy"
|
||||
2: "William Faulkner"
|
||||
3: "Virgina Woolf"
|
||||
4: "Gabriel Garcia Marquez"
|
||||
over_0:
|
||||
1: "James Joyce"
|
||||
2: "Thomas Pynchon"
|
||||
3: "David Foster Wallace"
|
||||
4: "Fyodor Dostoevsky"
|
||||
@@ -0,0 +1,6 @@
|
||||
en:
|
||||
site_settings:
|
||||
discourse_rewind_enabled: "Enable Discourse Rewind for a fun end-of-year summary for members' activity in the community."
|
||||
discourse_rewind:
|
||||
report_failed: "Failed to generate Discourse Rewind report."
|
||||
invalid_year: "Rewind can only be generated in January or December."
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseRewind::Engine.routes.draw { get "/rewinds" => "rewinds#show" }
|
||||
|
||||
Discourse::Application.routes.draw { mount ::DiscourseRewind::Engine, at: "/" }
|
||||
@@ -0,0 +1,5 @@
|
||||
discourse_rewind:
|
||||
discourse_rewind_enabled:
|
||||
default: false
|
||||
client: true
|
||||
hidden: true
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddDiscourseRewindDisabledToUserOptions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :user_options, :discourse_rewind_disabled, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::DiscourseRewind
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name PLUGIN_NAME
|
||||
isolate_namespace DiscourseRewind
|
||||
config.autoload_paths << File.join(config.root, "lib")
|
||||
scheduled_job_dir = "#{config.root}/app/jobs/scheduled"
|
||||
config.to_prepare do
|
||||
Rails.autoloaders.main.eager_load_dir(scheduled_job_dir) if Dir.exist?(scheduled_job_dir)
|
||||
end
|
||||
|
||||
Rails.application.reloader.to_prepare do
|
||||
Dir[
|
||||
"#{Rails.root}/plugins/discourse-rewind/app/services/discourse_rewind/rewind/action/*.rb"
|
||||
].each { |file| require_dependency file }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"discourse": "workspace:@discourse/types@0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# name: discourse-rewind
|
||||
# about: A fun end-of-year summary for members' activity in the community.
|
||||
# meta_topic_id: https://meta.discourse.org/t/discourse-rewind-2024/348063
|
||||
# version: 2025.12.0
|
||||
# authors: Discourse
|
||||
# url: https://github.com/discourse/discourse-rewind
|
||||
# required_version: 2.7.0
|
||||
|
||||
# TODO (martin): Remove this when we are ready to
|
||||
# launch rewind for 2025
|
||||
hide_plugin
|
||||
|
||||
enabled_site_setting :discourse_rewind_enabled
|
||||
|
||||
register_svg_icon "repeat"
|
||||
register_svg_icon "volume-high"
|
||||
register_svg_icon "volume-xmark"
|
||||
|
||||
register_asset "stylesheets/common/_index.scss"
|
||||
register_asset "stylesheets/mobile/_index.scss", :mobile
|
||||
|
||||
module ::DiscourseRewind
|
||||
PLUGIN_NAME = "discourse-rewind"
|
||||
|
||||
def self.public_asset_path(name)
|
||||
File.expand_path(File.join(__dir__, "public", name))
|
||||
end
|
||||
end
|
||||
|
||||
require_relative "lib/discourse_rewind/engine"
|
||||
|
||||
after_initialize do
|
||||
UserUpdater::OPTION_ATTR.push(:discourse_rewind_disabled)
|
||||
|
||||
add_to_serializer(:user_option, :discourse_rewind_disabled) { object.discourse_rewind_disabled }
|
||||
|
||||
add_to_serializer(:current_user_option, :discourse_rewind_disabled) do
|
||||
object.discourse_rewind_disabled
|
||||
end
|
||||
|
||||
add_to_serializer(:current_user, :is_rewind_active) do
|
||||
return false if object.user_option&.discourse_rewind_disabled
|
||||
Rails.env.development? || Date.today.month == 1 || Date.today.month == 12
|
||||
end
|
||||
|
||||
add_to_serializer(:current_user, :is_development_env) { Rails.env.development? }
|
||||
|
||||
Discourse::Application.routes.append do
|
||||
get "u/:username/preferences/rewind" => "users#preferences",
|
||||
:constraints => {
|
||||
username: RouteFormat.username,
|
||||
}
|
||||
end
|
||||
end
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user