Skip to content
Draft
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
235 changes: 235 additions & 0 deletions src/components/AccessControlCascade.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
@import "~bootstrap/scss/_functions.scss";
@import "~bootstrap/scss/_variables.scss";

$section-a-color: #3b82f6;
$section-b-color: #8b5cf6;
$extended-color: #f59e0b;
$default-color: $gray-700;

.cascade {
margin: 2rem 0;
display: flex;
flex-direction: column;
gap: 1.75rem;
}

.sectionTitle {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $gray-600;
margin-bottom: 0.6rem;
}

.table {
border: 1px solid $gray-200;
border-radius: 12px;
overflow: hidden;
background: white;
}

.headerRow,
.row {
display: grid;
grid-template-columns: minmax(160px, 1.3fr) repeat(3, 1fr);
}

.row {
border-top: 1px solid $gray-200;
}

.headerRow {
background: $gray-100;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $gray-600;
}

.headerCell {
padding: 0.7rem 1rem;
border-left: 1px solid $gray-200;

&:first-child {
border-left: none;
}
}

.layerColumn {
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 4px solid var(--layer-color);
}

.layerName {
font-weight: 600;
font-size: 0.95rem;
color: $gray-900;
}

.layerDesc {
font-size: 0.78rem;
color: $gray-600;
margin-top: 0.1rem;
}

.cell {
padding: 0.9rem 1rem;
border-left: 1px solid $gray-200;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
text-align: center;
}

.cellSet {
font-weight: 600;
color: var(--layer-color);
background-color: var(--layer-bg);
}

.cellInherit {
color: $gray-400;
font-style: italic;
font-size: 0.85rem;
}

.layerDefault {
--layer-color: #{$default-color};
--layer-bg: #{rgba($default-color, 0.06)};
}

.layerSectionA {
--layer-color: #{$section-a-color};
--layer-bg: #{rgba($section-a-color, 0.08)};
}

.layerSectionB {
--layer-color: #{$section-b-color};
--layer-bg: #{rgba($section-b-color, 0.08)};
}

.layerExtended {
--layer-color: #{$extended-color};
--layer-bg: #{rgba($extended-color, 0.1)};
}

.resolvedHeader {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}

.resolvedName {
font-weight: 600;
font-size: 1rem;
color: $gray-900;
}

.resolvedLabels {
font-size: 0.85rem;
color: $gray-600;
}

.resolvedCard {
border: 1px solid $gray-200;
border-radius: 12px;
padding: 1rem 1.25rem;
background: white;

& + & {
margin-top: 0.85rem;
}
}

.resolvedFields {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}

.resolvedField {
border: 1px solid $gray-200;
border-radius: 8px;
padding: 0.65rem 0.85rem;
background: $gray-100;
}

.resolvedFieldLabel {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $gray-600;
font-weight: 700;
}

.resolvedFieldValue {
font-size: 1.05rem;
font-weight: 600;
color: $gray-900;
margin: 0.15rem 0 0.45rem;
}

.resolvedFieldSource {
font-size: 0.7rem;
font-weight: 700;
color: var(--layer-color);
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-flex;
align-items: center;
gap: 0.35rem;

&::before {
content: "";
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--layer-color);
}
}

@media (max-width: 720px) {
.headerRow {
display: none;
}

.row {
grid-template-columns: 1fr;
}

.layerColumn {
grid-column: 1 / -1;
border-left: none;
border-top: 4px solid var(--layer-color);
padding: 0.75rem 1rem;
}

.cell {
border-left: none;
border-top: 1px solid $gray-200;
justify-content: space-between;
text-align: left;

&::before {
content: attr(data-label);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $gray-600;
}
}

.resolvedFields {
grid-template-columns: 1fr;
}
}
142 changes: 142 additions & 0 deletions src/components/AccessControlCascade.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import classNames from "classnames";

import styles from "./AccessControlCascade.module.scss";

type Field = "release" | "due" | "timeLimit";

interface Layer {
name: string;
description: string;
className: string;
values: Partial<Record<Field, string>>;
}

interface ResolvedExample {
studentName: string;
studentLabels: string;
source: Record<Field, number>;
}

const FIELDS: { key: Field; label: string }[] = [
{ key: "release", label: "Release date" },
{ key: "due", label: "Due date" },
{ key: "timeLimit", label: "Time limit" },
];

const LAYERS: Layer[] = [
{
name: "Default",
description: "Applies to everyone",
className: styles.layerDefault,
values: { release: "Mar 2", due: "Mar 13", timeLimit: "60 min" },
},
{
name: "Section A",
description: "Label override",
className: styles.layerSectionA,
values: { release: "Mar 4", due: "Mar 15" },
},
{
name: "Section B",
description: "Label override",
className: styles.layerSectionB,
values: { due: "Mar 16" },
},
{
name: "Extended time",
description: "Accommodation label",
className: styles.layerExtended,
values: { timeLimit: "90 min" },
},
];

const RESOLVED: ResolvedExample[] = [
{
studentName: "Alice",
studentLabels: "Section A · Extended time",
source: { release: 1, due: 1, timeLimit: 3 },
},
{
studentName: "Bob",
studentLabels: "Section B",
source: { release: 0, due: 2, timeLimit: 0 },
},
];

export function AccessControlCascade() {
return (
<div className={styles.cascade}>
<div>
<div className={styles.sectionTitle}>Layered overrides</div>
<div className={styles.table}>
<div className={styles.headerRow}>
<div className={styles.headerCell}>Layer</div>
{FIELDS.map((f) => (
<div key={f.key} className={styles.headerCell}>
{f.label}
</div>
))}
</div>
{LAYERS.map((layer) => (
<div
key={layer.name}
className={classNames(styles.row, layer.className)}
>
<div className={styles.layerColumn}>
<div className={styles.layerName}>{layer.name}</div>
<div className={styles.layerDesc}>{layer.description}</div>
</div>
{FIELDS.map((f) => {
const value = layer.values[f.key];
return (
<div
key={f.key}
data-label={f.label}
className={classNames(
styles.cell,
value ? styles.cellSet : styles.cellInherit,
)}
>
{value ?? "inherits"}
</div>
);
})}
</div>
))}
</div>
</div>

<div>
<div className={styles.sectionTitle}>Effective rules per student</div>
{RESOLVED.map((r) => (
<div key={r.studentName} className={styles.resolvedCard}>
<div className={styles.resolvedHeader}>
<span className={styles.resolvedName}>{r.studentName}</span>
<span className={styles.resolvedLabels}>{r.studentLabels}</span>
</div>
<div className={styles.resolvedFields}>
{FIELDS.map((f) => {
const layer = LAYERS[r.source[f.key]];
const value = layer.values[f.key];
return (
<div key={f.key} className={styles.resolvedField}>
<div className={styles.resolvedFieldLabel}>{f.label}</div>
<div className={styles.resolvedFieldValue}>{value}</div>
<div
className={classNames(
styles.resolvedFieldSource,
layer.className,
)}
>
from {layer.name}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
Loading
Loading