Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Howell 411a2a96d0 jan26 prepped 2026-01-15 21:46:58 +00:00
Peter Howell 4ceb28e7ef k 2026-01-15 20:02:46 +00:00
6 changed files with 263 additions and 36 deletions

View File

@ -649,6 +649,9 @@ function get_ses_hosts() { global $c, $AY;
$ID = ok($_REQUEST['id']);
return multi_row_select("select s.id, s.title, s.starttime, u.name, u.email, u.id AS hostid from conf_sessions as s LEFT OUTER JOIN conf_hosts as h ON h.session=s.id LEFT OUTER JOIN conf_users AS u ON h.host=u.id WHERE s.id='{$ID}' ORDER BY u.name;",1);
}
if (isset($_REQUEST['all']) && $_REQUEST['all'] == '1') {
return multi_row_select("select s.id, s.title, s.starttime, u.name, u.email, u.id AS hostid from conf_sessions as s LEFT OUTER JOIN conf_hosts as h ON h.session=s.id LEFT OUTER JOIN conf_users AS u ON h.host=u.id ORDER BY u.name;",1);
}
$date_clause = api_date_clause('s.starttime');
return multi_row_select("select s.id, s.title, s.starttime, u.name, u.email, u.id AS hostid from conf_sessions as s LEFT OUTER JOIN conf_hosts as h ON h.session=s.id LEFT OUTER JOIN conf_users AS u ON h.host=u.id WHERE $date_clause ORDER BY u.name;",1); }

View File

@ -3,5 +3,4 @@
$MY_TITLE = "Edit Sessions";
$MY_CRUMB = "Edit";
$CONTENT = '<activityeditorlist></activityeditorlist>';
$XTRAJS = 'js/editor.js';
include 'layout.php';

View File

@ -3,5 +3,5 @@ $MY_TITLE = "Schedule & Sessions";
$MY_CRUMB = "My Schedule";
$timeslot_config = file_exists('schedule_timeslots.json') ? json_decode(file_get_contents('schedule_timeslots.json'), true) : [];
$config_js = '<script>window.TIMESLOT_CONFIG = ' . json_encode($timeslot_config) . ';</script>';
$CONTENT = $config_js . '<div id="timeslot-config" class="hidden"></div><activitylist :itineraryview="1" :show_all_sessions="true"></activitylist>';
$CONTENT = '<div id="timeslot-config" class="hidden"></div><activitylist :itineraryview="1" :show_all_sessions="true"></activitylist>';
include 'layout.php';

View File

@ -28,10 +28,18 @@ function init_file_dropzone(parameter_name) {
function parsesqltime(mysqldate) { // 2021-01-29 09:00:00
if (! mysqldate) { return 0 }
var field = mysqldate.match(/^(\d\d\d\d)\-(\d+)\-(\d+)[T|\s](\d+)\:(\d+)\:(\d+)$/)
var mydate = new Date(field[1], field[2] - 1 , field[3], field[4], field[5], field[6])
return mydate }
if (!mysqldate) { return 0 }
if (mysqldate instanceof Date) { return mysqldate }
var s = String(mysqldate).trim()
var field = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[T\s](\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (field) {
var sec = field[6] ? field[6] : 0
return new Date(field[1], field[2] - 1, field[3], field[4], field[5], sec)
}
var parsed = new Date(s)
if (!isNaN(parsed.getTime())) { return parsed }
return 0
}
function dj(mysqldate) { return dayjs(parsesqltime(mysqldate)) }
@ -872,11 +880,17 @@ const ActivityList = Vue.component('activitylist', {
data: function () {
return { activities:[], mysessions:[], search:'', sortby:'starttime', reversed:false, my_ses_ids:[], my_host_ids:[],
show_filters: 'all', expanded: 1, editing: -1, active:-1, hosts:{}, selectedSlotKey: null,
hosts_by_sesid: {}, options:{}, conference:{}, ay:{}, conf:-1, survey_on:0, zoom_on:1, cancelingIds:[], timeslotConfig:null, } },
hosts_by_sesid: {}, options:{}, conference:{}, ay:{}, conf:-1, survey_on:0, zoom_on:1,
cancelingIds:[], timeslotConfig:null, expandedDesc:{}, collapsedDays:{},
surveyAnswersBySession: {}, } },
mounted: function() {
this.fetch_myevents()
},
methods: {
normalizeId: function(id) {
var parsed = parseInt(id, 10);
return Number.isNaN(parsed) ? id : parsed;
},
special_signup: function(activity_id) {
if (activity_id==1462 || activity_id==1455) {
return true;
@ -896,12 +910,45 @@ const ActivityList = Vue.component('activitylist', {
group.classList.toggle('hidden');
btn.textContent = group.classList.contains('hidden') ? 'Expand Day' : 'Collapse Day';
},
toggleDaySlots: function(dayKey) {
var isCollapsed = !!this.collapsedDays[dayKey];
this.$set(this.collapsedDays, dayKey, !isCollapsed);
},
isDayCollapsed: function(dayKey) {
return !!this.collapsedDays[dayKey];
},
toggleDescription: function(id) {
const para = document.getElementById(id);
const btn = document.getElementById(id + '-btn');
para.classList.toggle('line-clamp-2');
btn.textContent = para.classList.contains('line-clamp-2') ? 'Show More' : 'Show Less';
},
descPlain: function(desc) {
if (!desc) { return ''; }
return String(desc)
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
},
descPreview: function(desc) {
var text = this.descPlain(desc);
var limit = 200;
if (text.length <= limit) { return text; }
var slice = text.slice(0, limit + 1);
var cutoff = slice.lastIndexOf(' ');
if (cutoff < 0) { cutoff = limit; }
return text.slice(0, cutoff).trim() + '...';
},
descIsTruncated: function(desc) {
return this.descPlain(desc).length > 200;
},
isDescExpanded: function(id) {
return this.expandedDesc && this.expandedDesc[id];
},
toggleDesc: function(id) {
if (!this.expandedDesc) { this.expandedDesc = {}; }
this.$set(this.expandedDesc, id, !this.expandedDesc[id]);
},
slotKeyFromStart: function(starttime) {
if (!starttime) { return null; }
return this.$root.$dj(starttime).format('YYYY-MM-DDTHH:mm');
@ -945,21 +992,39 @@ const ActivityList = Vue.component('activitylist', {
am_editing: function(id) { return 0 },
fetch_myevents: function() {
var self = this;
basic_get('dir_api.php?a=get/hosts', function(r2) {
var hostUrl = 'dir_api.php?a=get/hosts';
if (self.show_all_sessions) { hostUrl += '&all=1'; }
basic_get(hostUrl, function(r2) {
self.hosts_by_sesid = _.groupBy(r2,function(x) { return x.id } )
} )
basic_get('dir_api.php?a=get/questions', function(r2) {
var answered = {};
_.each(r2, function(row) {
var sesId = self.normalizeId(row.ses_id || row.session || row.ses);
if (!sesId) { return; }
if (row.answer !== null && String(row.answer).trim() !== '') {
answered[sesId] = true;
}
});
self.surveyAnswersBySession = answered;
})
basic_get('api2.php?query=app',
function(r2) {
self.mysessions = r2.mysessions
self.my_ses_ids = _.pluck(r2.mysessions, 'id')
self.my_ses_ids = _.map(_.pluck(r2.mysessions, 'id'), function(val) {
return self.normalizeId(val);
})
self.activities = r2.sessions
if (r2.host != null) { self.my_host_ids = r2.host }
else { self.my_host_ids = [] }
self.options = r2.options
self.conference = r2.conference
self.ay = r2.ay
self.hosts_by_sesid = _.groupBy(r2.hostbysession,function(x) { return x.id } )
if (r2.hostbysession) {
self.hosts_by_sesid = _.groupBy(r2.hostbysession,function(x) { return x.id } )
}
self.survey_on = parseInt( _.findWhere(self.options, { label:'survey_on' }).value )
self.zoom_on = parseInt( _.findWhere(self.options, { label:'zoom_on' }).value )
@ -971,20 +1036,35 @@ const ActivityList = Vue.component('activitylist', {
},
joinme: function(id) {
var self = this
id = self.normalizeId(id)
basic_get('dir_api.php?a=signup/' + id,
function(r2) {
self.mysessions.push(_.findWhere(self.activities, {'id':id}))
self.my_ses_ids.push(id)
if (!self.my_ses_ids.includes(id)) { self.my_ses_ids.push(id) }
var existing = _.find(self.mysessions, function(s) {
return self.normalizeId(s.id) === id;
});
if (!existing) {
var activity = _.find(self.activities, function(a) {
return self.normalizeId(a.id) === id;
});
if (activity) { self.mysessions.push(activity) }
}
self.$forceUpdate()
alert_message("Added activity") })
},
dumpme: function(id) {
var self = this
id = self.normalizeId(id)
if (!self.cancelingIds.includes(id)) { self.cancelingIds.push(id) }
basic_get('dir_api.php?a=signdown/' + id,
function(r2) {
setTimeout(function() {
self.mysessions = _.without( self.mysessions, _.findWhere(self.activities, {'id':id}))
self.my_ses_ids = _.without( self.my_ses_ids, id)
self.mysessions = _.filter(self.mysessions, function(s) {
return self.normalizeId(s.id) !== id;
});
self.my_ses_ids = _.filter(self.my_ses_ids, function(sid) {
return self.normalizeId(sid) !== id;
});
self.cancelingIds = _.without(self.cancelingIds, id)
self.$forceUpdate()
alert_message("Removed activity")
@ -1008,6 +1088,27 @@ const ActivityList = Vue.component('activitylist', {
},
slotPresets: function(slot) { return slot.presetSessions || []; },
slotHasPreset: function(slot) { return (slot.presetSessions && slot.presetSessions.length>0); },
nl2br: function(text) {
if (!text) { return ''; }
return String(text).replace(/\r?\n/g, '<br>');
},
canJoin: function(activity) {
if (this.zoom_on !== 1) { return false; }
return activity && (activity.mode === 'online' || activity.mode === 'hybrid');
},
canSurvey: function(activity) {
if (this.survey_on !== 1) { return false; }
return !!activity;
},
hasSurveyAnswer: function(activity) {
if (!activity) { return false; }
var sid = this.normalizeId(activity.id);
return !!this.surveyAnswersBySession[sid];
},
hasJoinLink: function(activity) {
if (!activity || !activity.location) { return false; }
return String(activity.location).trim().length > 0;
},
loadTimeslots: function() {
var self=this;
if (window.TIMESLOT_CONFIG) {
@ -1134,8 +1235,14 @@ const ActivityList = Vue.component('activitylist', {
</div>
<div v-for="(slots, mmyy) in timeSlotsByDay" :key="mmyy" class="mb-6">
<h4 class="text-lg font-semibold text-gray-700 mb-2">{{ mmyy }} {{ get_day_title(mmyy) }}</h4>
<div class="space-y-3">
<div class="flex items-center gap-2 mb-2">
<button class="text-lg font-semibold text-gray-500 hover:text-gray-700 w-6 text-center"
@click.prevent="toggleDaySlots(mmyy)">
{{ isDayCollapsed(mmyy) ? '+' : '-' }}
</button>
<h4 class="text-lg font-semibold text-gray-700">{{ mmyy }} {{ get_day_title(mmyy) }}</h4>
</div>
<div v-if="!isDayCollapsed(mmyy)" class="space-y-3">
<div v-for="slot in slots" :key="slot.key" :class="['border rounded-lg p-4 bg-white shadow-sm flex items-start gap-4', selectedSlotKey === slot.key ? 'ring-2 ring-blue-400' : '']">
<div class="w-32 text-right">
<div class="text-2xl font-extrabold text-blue-800 leading-tight">{{ slot.startLabel }}</div>
@ -1148,11 +1255,11 @@ const ActivityList = Vue.component('activitylist', {
<div v-for="sel in slotPresets(slot)" :key="sel.title" class="border-b last:border-none pb-2 last:pb-0">
<div class="font-semibold text-gray-900">{{ sel.title }}</div>
<div class="text-sm text-gray-600">
<span v-if="sel.audience" class="mr-2 capitalize">{{ sel.audience }} meeting</span>
<span v-if="sel.audience" class="capitalize">{{ sel.audience }} meeting</span>
<span v-if="sel.location">at {{ sel.location }}</span>
<span v-if="sel.mode">· {{ sel.mode }}</span>
</div>
<div v-if="sel.notes" class="text-xs text-gray-500 mt-1">{{ sel.notes }}</div>
<div v-if="sel.notes" class="text-xs text-gray-500 mt-1" v-html="nl2br(sel.notes)"></div>
</div>
</div>
@ -1160,13 +1267,31 @@ const ActivityList = Vue.component('activitylist', {
<div v-for="sel in slotSelection(slot)" :key="sel.id" :class="['border-b last:border-none pb-2 last:pb-0 transition-opacity duration-300', isCanceling(sel.id) ? 'opacity-40' : '']">
<div class="font-semibold text-gray-900">{{ sel.title }}</div>
<div class="text-sm text-gray-600">
{{ $root.$dj(sel.starttime).format('h:mma') }} · {{ mode_string(sel) }}
<div class="flex flex-wrap items-center gap-2">
<span>{{ $root.$dj(sel.starttime).format('h:mma') }} - {{ addTime(sel.starttime, sel.length) }} · {{ mode_string(sel) }}</span>
<span v-if="sel.mode === 'hybrid'">·
<span v-if="sel.location_irl">{{ sel.location_irl }}</span>
<span v-else>(location TBD)</span>
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<a v-if="canJoin(sel) && hasJoinLink(sel)"
:href="sel.location"
class="inline-flex items-center px-2 py-1 text-xs font-semibold uppercase tracking-wide text-white bg-blue-600 rounded hover:bg-blue-700">JOIN Online</a>
<span v-else-if="canJoin(sel)" class="text-xs text-red-600">[missing zoom link]</span>
<a v-if="canSurvey(sel)"
:href="'survey.php?s=' + sel.id"
:class="['inline-flex items-center px-2 py-1 text-xs font-semibold uppercase tracking-wide text-white rounded',
hasSurveyAnswer(sel) ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700']">
{{ hasSurveyAnswer(sel) ? 'EDIT Survey' : 'TAKE Survey' }}
</a>
<button v-if="!my_host_ids.includes(sel.id)"
class="inline-flex items-center px-2 py-1 text-xs font-semibold uppercase tracking-wide text-white bg-red-600 rounded hover:bg-red-700"
@click.prevent="dumpme(sel.id)">Cancel</button>
</div>
</div>
<button v-if="!my_host_ids.includes(sel.id)"
class="mt-2 text-sm text-red-600 hover:underline"
@click.prevent="dumpme(sel.id)">Cancel</button>
</div>
<button class="mt-1 text-sm text-blue-600 hover:underline" @click="selectSlot(slot)">Change selection</button>
<button class="mt-1 text-sm text-blue-600 hover:underline" @click="selectSlot(slot)">Change session</button>
</div>
<div v-else class="mt-1">
<emptyslotcard label="Signup" @select="selectSlot(slot)"></emptyslotcard>
@ -1187,15 +1312,28 @@ const ActivityList = Vue.component('activitylist', {
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="font-semibold text-gray-900">{{ a.title }}</div>
<div class="text-xs uppercase text-gray-500">{{ mode_string(a) }}</div>
<!--<div class="text-xs uppercase text-gray-500">{{ mode_string(a) }}</div>-->
<div class="text-sm text-gray-600 mt-1">
<span v-if="a.mode === 'hybrid'">In person at {{ a.location_irl }} or online</span>
<span v-if="a.mode === 'inperson'">In person at {{ a.location_irl }}</span>
<span v-if="a.mode === 'hybrid'">In person<span v-if="a.location_irl"> at {{ a.location_irl }}</span> or online</span>
<span v-if="a.mode === 'inperson'">In person<span v-if="a.location_irl"> at {{ a.location_irl }}</span><span v-else> location TBD</span></span>
<span v-if="a.mode === 'online'">Online</span>
</div>
<div v-if="a.desc" class="text-sm text-gray-700 mt-2">
<span v-if="!isDescExpanded(a.id)">
{{ descPreview(a.desc) }}
<button v-if="descIsTruncated(a.desc)"
class="ml-1 text-blue-600 hover:underline"
@click.prevent="toggleDesc(a.id)">[+ read more]</button>
</span>
<span v-else>
<span v-html="a.desc"></span>
<button class="ml-1 text-blue-600 hover:underline"
@click.prevent="toggleDesc(a.id)">[- read less]</button>
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button v-if="!my_ses_ids.includes(a.id)"
<button v-if="!my_ses_ids.includes(normalizeId(a.id))"
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700"
@click.prevent="chooseSession(a)">
Sign Up
@ -1216,10 +1354,13 @@ const ActivityList = Vue.component('activitylist', {
<!-- No sessions signed up -->
<div v-if="itineraryview && mysessions_filtered.length === 0" class="mb-6">
<p class="font-semibold text-gray-800">It looks like you haven't signed up for any sessions yet!</p>
<p class="mt-2 text-sm text-blue-600">
Go to the <a class="underline hover:text-blue-800" href="allsessions.php">Sessions List</a> to sign up.
</p>
<p v-if="active < 1" class="font-semibold text-gray-800">Loading your itinerary...</p>
<template v-else>
<p class="font-semibold text-gray-800">It looks like you haven't signed up for any sessions yet!</p>
<p class="mt-2 text-sm text-blue-600">
Go to the <a class="underline hover:text-blue-800" href="allsessions.php">Sessions List</a> to sign up.
</p>
</template>
</div>
<!-- Simple full list for All Sessions -->
@ -1232,6 +1373,10 @@ const ActivityList = Vue.component('activitylist', {
<div>
<div class="text-sm text-gray-500">{{ $root.$dj(a.starttime).format('h:mma') }} - {{ addTime(a.starttime, a.length) }}</div>
<h4 class="text-lg font-semibold text-gray-900">{{ a.title }}</h4>
<div class="text-sm text-gray-600">
<span v-if="hoststr(a.id)">{{ hoststr(a.id) }}</span>
<i v-else>no host registered</i>
</div>
<p class="text-sm text-gray-700 mt-1" v-html="a.desc"></p>
<p class="text-xs uppercase text-gray-500 mt-2">{{ mode_string(a) }}</p>
<p class="text-sm text-gray-600" v-if="a.location_irl">Location: {{ a.location_irl }}</p>
@ -1450,6 +1595,7 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
reversed: false,
selectedIndex: 0,
editingId: null,
dayFilter: "all",
// filters
show_filters: "all",
filters: {
@ -1473,9 +1619,27 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
this.$root.fetch_menus();
this.fetchAll();
window.addEventListener("keydown", this.onKey);
this.changedHandler = (dat) => {
const column = dat[0];
const table = dat[1];
const value = dat[2];
const target = dat[3];
if (table !== "conf_sessions" || !target) return;
const a = this.byId[target];
if (!a) return;
a[column] = value;
if (column === "title" || column === "desc") {
a.searchable = ((a.title || "") + " " + (a.desc || "")).toLowerCase();
}
a.missing = this.computeMissing(a);
};
this.$root.events.bind("changed", this.changedHandler);
},
beforeDestroy() {
window.removeEventListener("keydown", this.onKey);
if (this.changedHandler && this.$root && this.$root.events && this.$root.events.unbind) {
this.$root.events.unbind("changed", this.changedHandler);
}
},
methods: {
async fetchAll() {
@ -1574,6 +1738,13 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
ff = ff.filter((x) => x.searchable.includes(q));
}
if (this.dayFilter && this.dayFilter !== "all") {
ff = ff.filter((item) => {
const t = dayjs(item.starttime);
return t.isValid() && t.format("YYYY-MM-DD") === this.dayFilter;
});
}
// AY filter example
const ay = this.$root.settings["default_ay"];
if (ay && this.$root.ay_menu && this.$root.ay_menu[ay]) {
@ -1604,7 +1775,7 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
} else if (e.key === "ArrowUp") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
e.preventDefault();
} else if (e.key === "Enter" || e.key.toLowerCase() === "e") {
} else if (e.key === "Enter") {
const a = this.activitiesFiltered[this.selectedIndex];
this.startEdit(a.id);
e.preventDefault();
@ -1641,6 +1812,20 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
activitiesFiltered() {
return this.filtered(this.activities);
},
availableDays() {
const seen = {};
this.activities.forEach((a) => {
const t = dayjs(a.starttime);
if (!t.isValid()) return;
const key = t.format("YYYY-MM-DD");
if (!seen[key]) {
seen[key] = t.format("MMM D YYYY");
}
});
return Object.keys(seen)
.sort()
.map((key) => ({ key, label: seen[key] }));
},
groupedByDay() {
return _.groupBy(this.activitiesFiltered, (x) => this.month_year(x.starttime));
},
@ -1656,6 +1841,10 @@ const ActivityEditorList = Vue.component('activityeditorlist', {
/>
<button @click="setsort('starttime')" class="border px-2 py-1 rounded">Sort: Start</button>
<button @click="setsort('title')" class="border px-2 py-1 rounded">Sort: Title</button>
<select v-model="dayFilter" class="border px-2 py-1 rounded">
<option value="all">All days</option>
<option v-for="d in availableDays" :key="d.key" :value="d.key">{{ d.label }}</option>
</select>
<span class="text-sm text-gray-600">/ select Enter/E edit Esc close</span>
</div>
@ -2025,14 +2214,30 @@ const AskSurvey = Vue.component('asksurvey', {
function(r2) {
self.questions = r2 })
},
saveAll: function() {
var self = this
_.each(self.questions, function(q) {
self.$root.events.trigger('update_survey', [q.user, q.session, q.qid, q.answer])
})
if (typeof alert_message === 'function') {
alert_message('Saved.', 'lightgreen')
}
},
},
mounted: function() { this.start() },
template: `<div>
<h3 v-if="questions.length">{{ questions[0].title }}</h3>
template: `<div class="survey-page bg-white p-4 rounded shadow">
<div v-if="questions.length" class="mb-4">
<span class="text-gray-600 font-medium">Session name:</span>
<span class="text-gray-900 font-semibold">[{{ questions[0].title }}]</span>
</div>
<div class="session-survey" v-for="q in questions">
<number-question v-if="q['type']=='2'" :qq="q"></number-question>
<essay-question v-if="q['type']=='1'" :qq="q"></essay-question>
</div>
<div class="mt-4">
<button class="inline-flex items-center px-3 py-2 text-sm font-semibold uppercase tracking-wide text-white bg-blue-600 rounded hover:bg-blue-700"
@click.prevent="saveAll">Save</button>
</div>
</div>`
});

View File

@ -25,7 +25,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
<title><?= htmlspecialchars($MY_TITLE) ?> | Gavilan Intranet</title>
<script src="js/tailwind.js"></script>
<script src="js/vue27max.js"></script>
<script src="js/intranet_libs.js"/></script>
<script src="js/intranet_libs.js"></script>
<?php if (isset($config_js)) { echo $config_js; } ?>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
@ -74,7 +74,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
<br /> &nbsp;
<br /> &nbsp;
<br /> &nbsp;
<script src="js/intranet_libs_bottom.js"/></script>
<script src="js/intranet_libs_bottom.js"></script>
<script src="js/dir_app.js?v=<?= $DIR_APP_VER ?>"></script>
<script src="<?= $XTRAJS ?>"></script>
</body>

View File

@ -64,3 +64,23 @@ ol {
.slotpanel-leave {
max-height: 600px;
}
/* Survey page label tone */
.survey-page .question,
.survey-page label,
.survey-page .form-check-label {
color: #4b5563;
}
/* Survey textarea should span full width */
.survey-page textarea {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px;
box-sizing: border-box;
}
.survey-page .session-survey {
margin-bottom: 12px;
}