Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
@@ -1 +1,93 @@
@import "tailwindcss";

@layer components {
[data-stream-view-mode="table"] .streams-table {
display: table !important;
}

[data-stream-view-mode="table"] .streams-thead {
display: table-header-group !important;
}

[data-stream-view-mode="table"] .streams-container {
overflow-x: auto !important;
}

[data-stream-view-mode="table"] .streams-card-sort {
display: none !important;
}

[data-stream-view-mode="table"] .streams-tbody {
display: table-row-group !important;
}

[data-stream-view-mode="table"] .streams-tbody > :not([hidden]) ~ :not([hidden]) {
margin-top: 0 !important;
}

[data-stream-view-mode="table"] .streams-row {
display: table-row !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow: visible !important;
background-color: transparent !important;
}

[data-stream-view-mode="table"] .streams-row:hover {
background-color: rgb(249 250 251) !important;
}

[data-stream-view-mode="table"] .streams-cell {
display: table-cell !important;
}

[data-stream-view-mode="table"] .streams-cell::before {
display: none !important;
}

[data-stream-view-mode="card"] .streams-table {
display: block !important;
}

[data-stream-view-mode="card"] .streams-thead {
display: none !important;
}

[data-stream-view-mode="card"] .streams-container {
overflow: visible !important;
}

[data-stream-view-mode="card"] .streams-card-sort {
display: flex !important;
}

[data-stream-view-mode="card"] .streams-tbody {
display: block !important;
}

[data-stream-view-mode="card"] .streams-tbody > :not([hidden]) ~ :not([hidden]) {
margin-top: 1rem !important;
}

[data-stream-view-mode="card"] .streams-row {
display: block !important;
border: 1px solid rgb(229 231 235) !important;
border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important;
overflow: hidden !important;
background-color: white !important;
}

[data-stream-view-mode="card"] .streams-row:hover {
background-color: rgb(249 250 251) !important;
}

[data-stream-view-mode="card"] .streams-cell {
display: block !important;
}

[data-stream-view-mode="card"] .streams-cell::before {
display: block !important;
}
}
2 changes: 2 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SearchController from './search_controller'
import MobileMenuController from './mobile_menu_controller'
import CollaborativeSpreadsheetController from './collaborative_spreadsheet_controller'
import StreamTablePreferencesController from './stream_table_preferences_controller'
import StreamViewController from './stream_view_controller'
import ToastController from './toast_controller'

// Register controllers
Expand All @@ -14,4 +15,5 @@ application.register('search', SearchController)
application.register('mobile-menu', MobileMenuController)
application.register('collaborative-spreadsheet', CollaborativeSpreadsheetController)
application.register('stream-table-preferences', StreamTablePreferencesController)
application.register('stream-view', StreamViewController)
application.register('toast', ToastController)
115 changes: 115 additions & 0 deletions app/javascript/controllers/stream_view_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
static targets = ['toggle']
static values = {
storageKey: { type: String, default: 'streamsViewMode' }
}

connect () {
this.boundHandleMediaChange = this.handleMediaChange.bind(this)
this.mediaQuery = window.matchMedia('(min-width: 768px)')

this.applySavedOrDefault()
this.addMediaListener()
}

disconnect () {
this.removeMediaListener()
}

select (event) {
const mode = event.currentTarget.dataset.mode
if (!this.isValidMode(mode)) return

this.storeMode(mode)
this.applyMode(mode)
}

handleMediaChange () {
this.applySavedOrDefault()
}

defaultMode () {
return this.mediaQuery.matches ? 'table' : 'card'
}

applySavedOrDefault () {
const savedMode = this.loadSavedMode()
if (savedMode) {
this.applyMode(savedMode)
return
}

this.applyMode(this.defaultMode())
}

applyMode (mode) {
if (!this.isValidMode(mode)) return

this.element.dataset.streamViewMode = mode
this.updateToggleStyles(mode)
}

updateToggleStyles (mode) {
if (!this.hasToggleTarget) return

this.toggleTargets.forEach(toggle => {
const active = toggle.dataset.mode === mode
toggle.setAttribute('aria-pressed', active ? 'true' : 'false')
toggle.classList.toggle('bg-indigo-600', active)
toggle.classList.toggle('text-white', active)
toggle.classList.toggle('hover:bg-indigo-700', active)
toggle.classList.toggle('bg-white', !active)
toggle.classList.toggle('text-gray-600', !active)
toggle.classList.toggle('hover:text-gray-900', !active)
toggle.classList.toggle('hover:bg-gray-50', !active)
})
}

isValidMode (mode) {
return mode === 'table' || mode === 'card'
}

loadSavedMode () {
try {
const mode = window.localStorage.getItem(this.storageKey())
return this.isValidMode(mode) ? mode : null
} catch {
return null
}
}

storeMode (mode) {
try {
window.localStorage.setItem(this.storageKey(), mode)
} catch {
// Ignore storage errors (private mode, disabled storage, etc.)
}
}

storageKey () {
const suffix = this.mediaQuery && this.mediaQuery.matches ? 'desktop' : 'mobile'
return `${this.storageKeyValue}-${suffix}`
}

addMediaListener () {
if (!this.mediaQuery) return

if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', this.boundHandleMediaChange)
} else if (this.mediaQuery.addListener) {
this.mediaQuery.addListener(this.boundHandleMediaChange)
}
}

removeMediaListener () {
if (!this.mediaQuery) return

if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.boundHandleMediaChange)
} else if (this.mediaQuery.removeListener) {
this.mediaQuery.removeListener(this.boundHandleMediaChange)
}
}
}
140 changes: 101 additions & 39 deletions app/views/admin/streams/_spreadsheet.html.erb
Original file line number Diff line number Diff line change
@@ -1,50 +1,83 @@
<div data-controller="collaborative-spreadsheet stream-table-preferences"
<div data-controller="collaborative-spreadsheet stream-table-preferences stream-view"
data-collaborative-spreadsheet-current-user-id-value="<%= current_admin_user.id.to_s %>"
data-collaborative-spreadsheet-current-user-name-value="<%= current_admin_user.display_name %>"
data-collaborative-spreadsheet-current-user-color-value="<%= user_color(current_admin_user) %>"
data-stream-table-preferences-hidden-columns-value="<%= @hidden_columns.to_json %>"
data-stream-table-preferences-column-order-value="<%= @column_order.to_json %>"
data-stream-table-preferences-preferences-url-value="<%= admin_stream_preferences_path %>"
data-stream-view-storage-key-value="streamsViewMode"
class="relative">

<!-- Column Preferences + Active Users -->
<div class="flex flex-wrap items-center justify-between gap-3 pb-3">
<div class="flex flex-wrap items-center gap-2">
<details class="relative">
<summary class="cursor-pointer select-none px-3 py-1.5 text-sm bg-white border border-gray-200 rounded-md shadow-sm hover:bg-gray-50">
Columns
</summary>
<div class="absolute left-0 mt-2 w-64 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg z-20 p-3">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Show columns</div>
<div class="space-y-1" data-stream-table-preferences-target="columnList">
<% stream_table_columns.each do |column| %>
<div class="flex items-center gap-2 text-sm text-gray-700"
data-column="<%= column[:key] %>"
data-stream-table-preferences-target="columnItem">
<span class="text-gray-400 hover:text-gray-600 cursor-move"
data-stream-table-preferences-target="columnDrag"
title="Drag to reorder"
aria-hidden="true">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6h.01M14 6h.01M10 12h.01M14 12h.01M10 18h.01M14 18h.01" />
</svg>
</span>
<label class="flex flex-1 items-center gap-2">
<%= check_box_tag "stream_columns[]",
column[:key],
!@hidden_columns.include?(column[:key]),
class: "h-4 w-4 text-indigo-600 border-gray-300 rounded",
data: { action: "stream-table-preferences#toggleColumn",
stream_table_preferences_target: "columnToggle" } %>
<span><%= column[:label] %></span>
</label>
</div>
<% end %>
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-2">
<details class="relative">
<summary class="cursor-pointer select-none px-3 py-1.5 text-sm bg-white border border-gray-200 rounded-md shadow-sm hover:bg-gray-50">
Columns
</summary>
<div class="absolute left-0 mt-2 w-64 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg z-20 p-3">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Show columns</div>
<div class="space-y-1" data-stream-table-preferences-target="columnList">
<% stream_table_columns.each do |column| %>
<div class="flex items-center gap-2 text-sm text-gray-700"
data-column="<%= column[:key] %>"
data-stream-table-preferences-target="columnItem">
<span class="text-gray-400 hover:text-gray-600 cursor-move"
data-stream-table-preferences-target="columnDrag"
title="Drag to reorder"
aria-hidden="true">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6h.01M14 6h.01M10 12h.01M14 12h.01M10 18h.01M14 18h.01" />
</svg>
</span>
<label class="flex flex-1 items-center gap-2">
<%= check_box_tag "stream_columns[]",
column[:key],
!@hidden_columns.include?(column[:key]),
class: "h-4 w-4 text-indigo-600 border-gray-300 rounded",
data: { action: "stream-table-preferences#toggleColumn",
stream_table_preferences_target: "columnToggle" } %>
<span><%= column[:label] %></span>
</label>
</div>
<% end %>
</div>
</div>
</details>
<span class="text-xs text-gray-500">Drag columns in the menu to reorder.</span>
</div>

<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500">View</span>
<div class="inline-flex rounded-md shadow-sm bg-white border border-gray-200" role="group" aria-label="View mode">
<button type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded-l-md"
data-stream-view-target="toggle"
data-action="stream-view#select"
data-mode="table">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<span class="hidden sm:inline">Table</span>
<span class="sr-only sm:hidden">Table view</span>
</button>
<button type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded-r-md"
data-stream-view-target="toggle"
data-action="stream-view#select"
data-mode="card">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="hidden sm:inline">Cards</span>
<span class="sr-only sm:hidden">Card view</span>
</button>
</div>
</details>
<span class="text-xs text-gray-500">Drag columns in the menu to reorder.</span>
</div>
</div>

<!-- Active Users Presence -->
Expand All @@ -61,11 +94,40 @@
</div>
</div>

<% sort_filter_params = params.except(:page, :sort, :direction)
.permit(:status, :platform, :kind, :orientation, :user_id, :search, :is_pinned, :is_archived)
.to_h %>
<% sort_options = stream_table_columns.select { |column| column[:sortable] }
.map { |column| [column[:label], column[:key]] } %>

<!-- Card Sort Controls -->
<div class="streams-card-sort flex flex-wrap items-center gap-2 pb-4">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500">Sort</div>
<%= form_with url: admin_streams_path,
method: :get,
data: { controller: "search", search_target: "form", turbo_frame: "streams_list", turbo_action: "advance" },
class: "flex flex-1 flex-wrap items-center gap-2" do |form| %>
<% sort_filter_params.each do |key, value| %>
<%= form.hidden_field key, value: value %>
<% end %>
<%= form.select :sort,
options_for_select(sort_options, @sort_column),
{ include_blank: "Sort by" },
class: "flex-1 min-w-[160px] rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm",
data: { action: "change->search#submit" } %>
<%= form.select :direction,
options_for_select([["Ascending", "asc"], ["Descending", "desc"]], @sort_direction),
{},
class: "min-w-[140px] rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm",
data: { action: "change->search#submit" } %>
<% end %>
</div>

<!-- Spreadsheet Table -->
<div class="overflow-x-auto bg-white rounded-lg shadow"
<div class="streams-container overflow-visible md:overflow-x-auto md:bg-white md:rounded-lg md:shadow"
data-stream-table-preferences-target="scrollContainer">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<table class="streams-table block md:table min-w-full divide-y-0 md:divide-y md:divide-gray-200">
<thead class="streams-thead hidden md:table-header-group bg-gray-50">
<tr>
<th scope="col"
aria-sort="<%= stream_sort_aria("streamer", @sort_column, @sort_direction) %>"
Expand Down Expand Up @@ -166,7 +228,7 @@
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody class="streams-tbody block md:table-row-group space-y-4 md:space-y-0">
<%= render partial: "admin/streams/spreadsheet_row", collection: @streams, as: :stream %>
</tbody>
</table>
Expand Down
Loading