This commit is contained in:
eric
2026-03-27 18:20:36 -05:00
parent a8976b2eee
commit 326bad4336
34 changed files with 2787 additions and 996 deletions

View File

@@ -23,7 +23,7 @@ export function TopicComingSoon({ topicId }: TopicComingSoonProps) {
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
</section>

View File

@@ -38,28 +38,32 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
</Link>
{paid ? (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
<span className="hidden">
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
</span>
) : (
<button
type="button"
onClick={() => setModalOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</button>
<span className="hidden">
<button
type="button"
onClick={() => setModalOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</button>
</span>
)}
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">

View File

@@ -48,28 +48,32 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
</Link>
{paid ? (
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
<span className="hidden">
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
</span>
) : (
<button
type="button"
onClick={() => setQaModalOpen(true)}
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</button>
<span className="hidden">
<button
type="button"
onClick={() => setQaModalOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</button>
</span>
)}
{config?.externalUrl ? (
<Link

View File

@@ -1,11 +1,26 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { getTopicConfig, getTopicCurriculum } from "../config";
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
import { ParticleBackground } from "@/app/cloudphone/components/ParticleBackground";
import { triggerPay } from "@/app/lib/triggerPay";
import type { TopicPrepVariant } from "./TopicPrepThreeScene";
const TopicPrepThreeScene = dynamic(
() => import("./TopicPrepThreeScene").then((m) => m.TopicPrepThreeScene),
{
ssr: false,
loading: () => (
<div
className="mx-auto mt-2 min-h-[260px] w-full max-w-2xl animate-pulse rounded-2xl border border-[var(--border)] bg-[var(--muted)]/20"
aria-hidden
/>
),
},
);
type TopicHeroProps = {
topicId: string;
@@ -36,6 +51,10 @@ export function TopicHero({ topicId }: TopicHeroProps) {
const isEnrollCta = !config.externalUrl && !last;
const showPrepThree =
(topicId === "indie" || topicId === "aiagent") && !curriculum?.hasContent;
const prepVariant: TopicPrepVariant = topicId === "indie" ? "indie" : "aiagent";
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
@@ -74,6 +93,14 @@ export function TopicHero({ topicId }: TopicHeroProps) {
</div>
))}
</div>
{showPrepThree ? (
<div
className="animate__animated animate__fadeInUp mx-auto mb-10 w-full max-w-2xl sm:mb-12"
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
>
<TopicPrepThreeScene variant={prepVariant} />
</div>
) : null}
{curriculum?.hasContent && (
<div
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"

View File

@@ -0,0 +1,405 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export type TopicPrepVariant = "indie" | "aiagent";
type Props = {
variant: TopicPrepVariant;
};
function disposeObject3D(root: THREE.Object3D) {
const materials = new Set<THREE.Material>();
const geometries = new Set<THREE.BufferGeometry>();
root.traverse((object) => {
if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments) {
const g = object.geometry;
if (g && !geometries.has(g)) {
geometries.add(g);
g.dispose();
}
const mat = object.material;
if (Array.isArray(mat)) mat.forEach((m) => materials.add(m));
else if (mat) materials.add(mat);
}
});
materials.forEach((m) => m.dispose());
}
/** 独立开发:居家书桌 + 显示器 + homelab 机架 */
function buildIndieDeskScene(root: THREE.Group) {
const wood = new THREE.MeshStandardMaterial({
color: 0x6b5344,
roughness: 0.88,
metalness: 0.05,
});
const deskDark = new THREE.MeshStandardMaterial({
color: 0x18181b,
roughness: 0.55,
metalness: 0.25,
});
const screenGlow = new THREE.MeshStandardMaterial({
color: 0x052e16,
emissive: 0x4ade80,
emissiveIntensity: 0.55,
roughness: 0.35,
});
const keyCap = new THREE.MeshStandardMaterial({
color: 0x3f3f46,
roughness: 0.45,
metalness: 0.15,
});
const rackLed = new THREE.MeshStandardMaterial({
color: 0x0c0a09,
emissive: 0xf59e0b,
emissiveIntensity: 0.5,
roughness: 0.4,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(7, 5.5), wood);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.15;
root.add(floor);
const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.8, 2), new THREE.MeshStandardMaterial({
color: 0x3d3832,
roughness: 0.95,
}));
rug.rotation.x = -Math.PI / 2;
rug.position.set(0, -1.14, 0.1);
root.add(rug);
const deskTop = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.07, 1), deskDark);
deskTop.position.set(0, -0.38, 0.15);
root.add(deskTop);
const legGeo = new THREE.BoxGeometry(0.08, 0.45, 0.08);
const legPositions: [number, number, number][] = [
[-1, -0.65, -0.25],
[1, -0.65, -0.25],
[-1, -0.65, 0.55],
[1, -0.65, 0.55],
];
for (const [x, y, z] of legPositions) {
const leg = new THREE.Mesh(legGeo, deskDark);
leg.position.set(x, y, z);
root.add(leg);
}
const monitorBezel = new THREE.Mesh(new THREE.BoxGeometry(1, 0.62, 0.045), deskDark);
monitorBezel.position.set(0, 0.12, -0.28);
root.add(monitorBezel);
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.88, 0.52), screenGlow);
screen.position.set(0, 0.12, -0.28 + 0.023);
root.add(screen);
const stand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.12, 0.15), deskDark);
stand.position.set(0, -0.28, -0.22);
root.add(stand);
const keyboard = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.035, 0.3), keyCap);
keyboard.position.set(0, -0.32, 0.38);
keyboard.rotation.x = -0.06;
root.add(keyboard);
const mug = new THREE.Mesh(
new THREE.CylinderGeometry(0.09, 0.08, 0.12, 16),
new THREE.MeshStandardMaterial({ color: 0x3f3f46, roughness: 0.4 }),
);
mug.position.set(-0.85, -0.31, 0.42);
root.add(mug);
const rack = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.95, 0.42), deskDark);
rack.position.set(1.05, 0.1, -0.05);
root.add(rack);
const rackFace = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 0.82), rackLed);
rackFace.position.set(1.05 + 0.191, 0.1, -0.05);
root.add(rackFace);
for (let row = 0; row < 4; row++) {
const led = new THREE.Mesh(
new THREE.PlaneGeometry(0.22, 0.04),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: row % 2 === 0 ? 0x22c55e : 0xf59e0b,
emissiveIntensity: 0.65,
}),
);
led.position.set(1.05 + 0.192, 0.35 - row * 0.18, -0.05);
root.add(led);
}
const miniServer = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.15, 0.18), deskDark);
miniServer.position.set(1.05, -0.28, 0.45);
root.add(miniServer);
}
/** AI Agent赛博空间网格 + 机器人 + 霓虹光环 */
function buildAiCyberScene(root: THREE.Group) {
const grid = new THREE.GridHelper(9, 28, 0x6366f1, 0x312e81);
grid.position.y = -1.05;
const gMat = grid.material;
if (Array.isArray(gMat)) {
gMat.forEach((m) => {
if ("opacity" in m) {
(m as THREE.Material & { opacity: number }).opacity = 0.45;
m.transparent = true;
}
});
} else if (gMat && "opacity" in gMat) {
(gMat as THREE.Material & { opacity: number }).opacity = 0.45;
gMat.transparent = true;
}
root.add(grid);
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x52525b,
metalness: 0.55,
roughness: 0.28,
flatShading: true,
});
const glowCyan = new THREE.MeshStandardMaterial({
color: 0x06b6d4,
emissive: 0x22d3ee,
emissiveIntensity: 0.85,
});
const glowMagenta = new THREE.MeshStandardMaterial({
color: 0xd946ef,
emissive: 0xc026d3,
emissiveIntensity: 0.55,
});
const robot = new THREE.Group();
robot.position.y = -0.15;
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.58, 0.32), bodyMat);
torso.position.y = 0.1;
robot.add(torso);
const chestPanel = new THREE.Mesh(new THREE.PlaneGeometry(0.35, 0.25), glowCyan);
chestPanel.position.set(0, 0.12, 0.161);
robot.add(chestPanel);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.36, 0.38), bodyMat);
head.position.y = 0.55;
robot.add(head);
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.12, 0.02), glowCyan);
visor.position.set(0, 0.52, 0.19);
robot.add(visor);
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
eyeL.position.set(-0.1, 0.56, 0.18);
const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
eyeR.position.set(0.1, 0.56, 0.18);
robot.add(eyeL, eyeR);
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 0.28, 8), glowMagenta);
antenna.position.set(0, 0.82, 0);
robot.add(antenna);
const armL = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.45, 0.12), bodyMat);
armL.position.set(-0.38, 0.05, 0);
const armR = armL.clone();
armR.position.x = 0.38;
robot.add(armL, armR);
root.add(robot);
for (let i = 0; i < 3; i++) {
const ringMat = new THREE.MeshBasicMaterial({
color: i === 1 ? 0xa855f7 : 0x22d3ee,
transparent: true,
opacity: 0.35 - i * 0.08,
});
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.95 + i * 0.22, 0.012, 8, 64), ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = -0.85 + i * 0.15;
root.add(ring);
}
const holo = new THREE.Mesh(
new THREE.OctahedronGeometry(0.18, 0),
new THREE.MeshBasicMaterial({ color: 0x67e8f9, wireframe: true, transparent: true, opacity: 0.5 }),
);
holo.position.set(-0.55, 0.35, 0.35);
root.add(holo);
return { robot, holo };
}
export function TopicPrepThreeScene({ variant }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.35, 5.2);
const group = new THREE.Group();
scene.add(group);
let bobRefs: { robot?: THREE.Group; holo?: THREE.Mesh } | null = null;
if (variant === "indie") {
scene.background = null;
const ambient = new THREE.AmbientLight(0xfff5e6, 0.45);
scene.add(ambient);
const warm = new THREE.PointLight(0xffb86b, 1.1, 12);
warm.position.set(0.6, 1.2, 1.2);
scene.add(warm);
const cool = new THREE.PointLight(0x4ade80, 0.35, 8);
cool.position.set(-0.3, 0.5, 0.5);
scene.add(cool);
const dir = new THREE.DirectionalLight(0xffffff, 0.35);
dir.position.set(-2, 4, 3);
scene.add(dir);
buildIndieDeskScene(group);
group.rotation.y = 0.25;
} else {
scene.fog = new THREE.FogExp2(0x0a0a14, 0.055);
const ambient = new THREE.AmbientLight(0x4c1d95, 0.35);
scene.add(ambient);
const key = new THREE.PointLight(0x22d3ee, 1.2, 16);
key.position.set(1.5, 2, 2);
scene.add(key);
const rim = new THREE.PointLight(0xd946ef, 0.85, 14);
rim.position.set(-2, 0.5, -1);
scene.add(rim);
const dir = new THREE.DirectionalLight(0xa78bfa, 0.4);
dir.position.set(0, 3, 4);
scene.add(dir);
bobRefs = buildAiCyberScene(group);
}
let rx = 0.1;
let ry = variant === "indie" ? 0.2 : 0;
let vx = 0;
let vy = 0;
const sens = 0.0052;
const damping = 0.93;
const autoRy = variant === "indie" ? 0.0018 : 0.0024;
let dragging = false;
let lastX = 0;
let lastY = 0;
const onPointerDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
vy += dx * sens;
vx += dy * sens;
};
const onPointerEnd = (e: PointerEvent) => {
dragging = false;
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerEnd);
canvas.addEventListener("pointercancel", onPointerEnd);
canvas.style.touchAction = "none";
const resize = () => {
const w = Math.max(280, container.clientWidth);
const h = Math.min(440, Math.max(236, Math.round(w * 0.5)));
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h, false);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
let raf = 0;
const t0 = performance.now();
const tick = () => {
raf = requestAnimationFrame(tick);
const t = (performance.now() - t0) * 0.001;
if (!dragging) vy += autoRy;
rx += vx;
ry += vy;
vx *= damping;
vy *= damping;
group.rotation.x = rx;
group.rotation.y = ry;
if (bobRefs?.robot) {
bobRefs.robot.position.y = -0.15 + Math.sin(t * 1.8) * 0.035;
}
if (bobRefs?.holo) {
bobRefs.holo.rotation.y = t * 0.9;
bobRefs.holo.rotation.x = t * 0.45;
}
renderer.render(scene, camera);
};
tick();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerEnd);
canvas.removeEventListener("pointercancel", onPointerEnd);
scene.fog = null;
disposeObject3D(group);
scene.remove(group);
renderer.dispose();
};
}, [variant]);
const label =
variant === "indie" ? "独立开发:居家工作台与 homelab 预览" : "AI Agent赛博空间与机器人预览";
const hint =
variant === "indie"
? "拖动旋转 · 居家书桌、显示器与 homelab 机架(课程筹备视觉预告)"
: "拖动旋转 · 赛博网格空间与机器人(课程筹备视觉预告)";
return (
<div ref={containerRef} className="mx-auto mt-2 w-full max-w-2xl">
<p className="mb-2 text-center text-xs text-[var(--muted-foreground)]">{hint}</p>
<canvas
ref={canvasRef}
className="mx-auto block w-full cursor-grab touch-none rounded-2xl border border-[var(--border)] bg-[var(--muted)]/25 active:cursor-grabbing dark:bg-[var(--muted)]/15"
aria-label={label}
role="img"
/>
</div>
);
}

View File

@@ -12,7 +12,7 @@ export type TopicConfig = {
subtitle: string;
desc: string;
stats: { value: string; label: string }[];
/** 使用的 curriculum 模块索引,空则用全部 */
/** 云手机课纲的模块索引;不传则用全部;传 [] 表示本专题暂无课程(勿套用云手机章节) */
moduleIndices?: number[];
/** 外部链接,有则显示「前往了解」而非课程列表 */
externalUrl?: string;
@@ -51,12 +51,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
icon: "🛠️",
title: "独立开发",
subtitle: "从想法到产品,独立开发者全流程指南",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
desc: "即将上线,敬请期待。",
stats: [
{ value: "—", label: "课程筹备中" },
{ value: "—", label: "敬请期待" },
],
externalUrl: "https://digital.hackrobot.cn/zh",
moduleIndices: [],
payUrl: "/",
},
aiagent: {
@@ -64,12 +64,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
icon: "🤖",
title: "AI Agent",
subtitle: "AIGC 与智能体Vibe Coding 跨界创造",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
desc: "即将上线,敬请期待。",
stats: [
{ value: "—", label: "课程筹备中" },
{ value: "—", label: "敬请期待" },
],
externalUrl: "https://digital.hackrobot.cn/zh",
moduleIndices: [],
payUrl: "/",
},
};
@@ -115,7 +115,10 @@ export function getTopicCurriculum(topicId: string) {
if (topicId === "livestream") {
return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 };
}
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
const indices =
config.moduleIndices !== undefined
? config.moduleIndices
: CURRICULUM_CONFIG.modules.map((_, i) => i);
const modules = indices
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
.filter((x) => x.module)