Spaces:
Sleeping
Sleeping
Commit
·
c15fa2f
0
Parent(s):
initial commit
Browse files- .astro/settings.json +5 -0
- .astro/types.d.ts +1 -0
- .gitignore +2 -0
- README.md +31 -0
- astro.config.mjs +8 -0
- data/speaking.jsonl +10 -0
- package-lock.json +0 -0
- package.json +15 -0
- src/env.d.ts +1 -0
- src/layouts/BaseLayout.astro +16 -0
- src/pages/index.astro +448 -0
- src/styles/global.css +270 -0
- src/utils/loadData.ts +32 -0
- tsconfig.json +6 -0
.astro/settings.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_variables": {
|
| 3 |
+
"lastUpdateCheck": 1760891952784
|
| 4 |
+
}
|
| 5 |
+
}
|
.astro/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="astro/client" />
|
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.env
|
README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Speaking Practice Browser (Astro)
|
| 2 |
+
|
| 3 |
+
New Astro-based UI that replicates the Gradio experience with full control over styling and responsiveness.
|
| 4 |
+
|
| 5 |
+
## Getting Started
|
| 6 |
+
|
| 7 |
+
1. Install dependencies (Node 18+ recommended):
|
| 8 |
+
```bash
|
| 9 |
+
cd astra
|
| 10 |
+
npm install
|
| 11 |
+
```
|
| 12 |
+
2. Start the dev server:
|
| 13 |
+
```bash
|
| 14 |
+
npm run dev
|
| 15 |
+
```
|
| 16 |
+
The app defaults to http://localhost:4321 and supports hot reloading.
|
| 17 |
+
3. Build for production:
|
| 18 |
+
```bash
|
| 19 |
+
npm run build
|
| 20 |
+
npm run preview
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Data
|
| 24 |
+
|
| 25 |
+
`speaking.jsonl` lives in `data/` and is loaded at build time. Drop a `writing.jsonl` file in the same folder if you have writing tasks�the UI will surface a Writing category automatically.
|
| 26 |
+
|
| 27 |
+
## Customisation
|
| 28 |
+
|
| 29 |
+
- Update task mappings or UI copy in `src/pages/index.astro`.
|
| 30 |
+
- Tweak styling in `src/styles/global.css`.
|
| 31 |
+
- Data loading helpers live in `src/utils/loadData.ts`.
|
astro.config.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "astro/config";
|
| 2 |
+
|
| 3 |
+
export default defineConfig({
|
| 4 |
+
server: {
|
| 5 |
+
host: true,
|
| 6 |
+
port: 4321,
|
| 7 |
+
},
|
| 8 |
+
});
|
data/speaking.jsonl
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id":25884,"task_type":"giving_advice","task_description":"You have just moved into a new building and plan to host a get-together to meet your neighbors; give practical advice on how to invite them and what to do to make a good impression.","custom_id":"25884__giving_advice","rephrased_task":"You have just moved into a new building and want to host a casual get-together to meet your neighbours. Give practical advice on how to invite them and what to do at the event to make a good impression.","brainstorm":[{"title":"Leave a friendly note and invitation","description":"Drop a short printed invitation in each mailbox or under doors with date, time, location and an RSVP line. Keep the tone warm and include your apartment number and a phone or email."},{"title":"Post online in the building group","description":"If the building has an email list, Facebook group, or app, post the same invitation there so tech-savvy neighbours see it and can ask questions quickly."},{"title":"Offer simple refreshments or a potluck","description":"Provide coffee, tea and store-bought snacks, or ask neighbours to bring one dish. A potluck lowers your cost and gives people a reason to stay and chat."},{"title":"Plan quick icebreakers and name tags","description":"Have a few conversation starters or a simple game and hand out name tags so people feel comfortable introducing themselves."},{"title":"Coordinate logistics and be considerate","description":"Reserve a common room if needed, check noise rules, set a one- to two-hour timeframe, and note any dietary or accessibility needs on the invite."}],"vocabulary":["invitation","RSVP","mailbox drop","building bulletin board","common room","potluck","refreshments","icebreaker","name tag","noise curfew","accessibility","follow-up"],"response":"Hey, congrats on the new place. If I were you, start by sending a short, friendly invitation so neighbours know when and where to meet. Leave printed cards in mailboxes or post on the building\u2019s notice board, and mirror the details in any online building group. Be sure to ask for a quick RSVP and include your apartment number. Another thing you could do is keep refreshments simple. Offer coffee, tea and a few packaged snacks, or make it a potluck so people can contribute. That makes the event low-pressure and gives neighbours a reason to stay and chat. One more practical tip is to plan the time and logistics carefully. Choose a weekend afternoon or early evening for minimal disruption, reserve the common room if required, and note any dietary or accessibility concerns on the invite. Finally, make introductions easy: wear name tags, have a quick icebreaker question, and circulate to talk to groups rather than staying in one spot. These small touches show thoughtfulness and make a good first impression. Good luck with the get-together and enjoy meeting your neighbours.","response_alternative":null,"brainstorm_alternative":null}
|
| 2 |
+
{"id":25884,"task_type":"personal_experience","task_description":"Describe a real experience you had with an artwork: what the piece was, how you made or encountered it, what happened, and what you learned from the experience.","custom_id":"25884__personal_experience","rephrased_task":"Talk about a real experience you had with an artwork. Describe the piece, explain how you created or encountered it, tell what happened during the experience, and say what you learned from it.","brainstorm":[{"title":"Acrylic landscape I painted","description":"Describe painting a small acrylic landscape from a weekend hike, including choice of palette, brushwork, and the moment you finished it at home."},{"title":"Museum encounter with a sculpture","description":"Recall a visit to a gallery where a sculpture surprised you, how you approached it, the emotion it evoked, and a conversation with another visitor or docent."},{"title":"Collaborative mural project","description":"Explain joining a community mural, your role in planning and painting, interactions with neighbours, and the unveiling event."},{"title":"Making a mixed-media collage","description":"Talk about assembling a collage from old photographs and found objects, the creative decisions you made, and how viewers reacted when you displayed it."},{"title":"Student art critique","description":"Describe presenting a piece in a class critique, the feedback you received, how it felt to defend your choices, and what you changed afterward."}],"vocabulary":["medium","palette","brushstroke","composition","texture","mixed media","gallery","installation","curator","exhibit","focal point","contrast","sketchbook","critique","conservation"],"response":"A memorable experience with an artwork happened two years ago when I made a small mixed-media canvas inspired by a river near my neighbourhood. I collected bits of newspaper and dried leaves, sketched a loose composition in my sketchbook, then layered acrylic washes over the collage to unify the surface. I worked in short sessions so the texture built naturally. When I entered the piece in a local community exhibit, a retired teacher stood for a long time in front of it and told me the image reminded her of walks with her late husband. Her reaction made the work feel useful beyond my own expression. After the show I sold the piece to a neighbour and I learned that honest materials and simple decisions can create real connections, and that sharing art opens unexpected conversations.","response_alternative":null,"brainstorm_alternative":null}
|
| 3 |
+
{"id":25884,"task_type":"describing_scene","task_description":"This is an image of a busy office floor with several cubicles where people are working, talking on phones, listening to music, and interacting with each other.","custom_id":"25884__describing_scene","rephrased_task":"Describe the busy office floor shown. Explain the overall setting and then describe specific people, their actions, appearance, and feelings so the listener can picture what is happening. Speak for about 60 seconds.","brainstorm":[{"title":"Start with a clear overview","description":"Open with one sentence naming the place (open-plan office with cubicles) so the listener immediately understands the setting."},{"title":"Focus on a few people","description":"Choose three or four people to describe in detail\u2014someone on the phone, a person typing, a pair talking\u2014to create a vivid snapshot."},{"title":"Include actions and emotions","description":"Describe what people are doing and how they seem to feel (rushed, focused, amused), using small details like headphones, gestures, or smiles."},{"title":"Mention sound and movement","description":"Refer to background noise such as phone conversations, keyboard typing, and brief face-to-face interactions to convey atmosphere."},{"title":"Close with a summary","description":"End with a short concluding sentence that captures the overall mood, for example that the office feels busy and collaborative."}],"vocabulary":["open-plan office","cubicle","headset","conference call","background chatter","typing away","collaborating","coffee break","manager circulating","multitasking","deadline pressure","quick huddle","ergonomic chair"],"response":"This is a busy open-plan office floor with several cubicles where employees are actively working. In the foreground a woman is sitting at her desk typing quickly on a laptop while wearing a headset; she looks focused and slightly hurried. Nearby a man is talking on the phone, speaking in a steady tone and gesturing with his free hand. Two colleagues have stood up between cubicles to exchange a document and are smiling as they make a quick plan. Another person has headphones on and is nodding along to music while scanning a spreadsheet [ASSUMED]. There\u2019s a low hum of background chatter and keyboard clicks that fills the space. Overall the scene feels productive and collaborative, with people balancing concentrated work and brief, friendly interactions.","response_alternative":"I expect a few short events to follow. The woman on the headset will finish her call and either send an email or update a shared task, while the man on the phone may hand off the issue to a teammate and stand to locate a file. The two colleagues will likely return to their desks with the document and start a quick task together, and the person wearing headphones will remove them to join the conversation or answer a question. A manager might walk through to check progress and prompt a brief huddle, causing several people to gather and discuss priorities. Noise levels will rise slightly during the huddle, then drop back down as everyone resumes focused work.","brainstorm_alternative":null}
|
| 4 |
+
{"id":25884,"task_type":"comparing_persuading","task_description":"Compare three camper-van rental options and recommend one: one option costs $850 and includes a bathroom, toilet, small kitchen, and four beds; another costs $1000, is larger and fancier with six beds; a cheaper $700 option lacks a bathroom and kitchen and needs repairs. Choose one option and explain why.","custom_id":"25884__comparing_persuading","rephrased_task":"You are choosing a camper-van rental. Compare the three options below and choose the one you prefer. In Part 2, persuade a friend who prefers a different option that your choice is the better one by comparing costs, features, and practical benefits.\n\nOptions:\n- $850: includes bathroom, toilet, small kitchen, four beds.\n- $1000: larger and fancier, six beds.\n- $700: cheaper but lacks bathroom and kitchen and needs repairs.","brainstorm":[{"title":"Self-contained convenience","description":"Emphasize that the $850 van has an onboard bathroom and kitchen, so you can avoid campground fees and eating out every meal."},{"title":"Right-sized for group","description":"Point out that four beds match a typical family or group of friends, so extra beds in the $1000 option may be unnecessary and wasteful."},{"title":"Cost savings and reallocation","description":"Highlight the $150 savings compared with the $1000 van and suggest using that money for fuel, park fees, or activities."},{"title":"Reliability over cheap fixes","description":"Contrast the $700 option\u2019s needed repairs with the more reliable, move-in-ready $850 van to avoid breakdowns and unexpected repair costs."},{"title":"Driving and fuel practicalities","description":"Note that a mid-size van is easier to drive and park and likely more fuel efficient than a larger six-bed model."}],"vocabulary":["camper van","self-contained","onboard bathroom","kitchenette","sleeping capacity","fuel efficiency","reliability","maintenance","value for money","driving maneuverability","rental rate","living space","storage capacity","campground fees"],"response":"I would choose the $850 camper. It gives the best balance between comfort and cost. It already includes a bathroom and a small kitchen, so we can cook and shower without paying extra campground fees or eating out all the time. The four beds match what we realistically need for a family of four, so the two extra beds in the $1000 option are unnecessary for us. Also, saving $150 means more money for fuel, park passes, or activities. Compared with the $700 van, the $850 is move-in ready and avoids the risk of breakdowns or surprise repair bills. Finally, a mid-size van is easier to drive and park, and will likely use less fuel than a larger model. For those reasons, the $850 option is the most practical and cost-effective choice.","response_alternative":"I would pick the $1000 camper. Even though it costs more, the extra space and six beds give us flexibility for guests and more comfortable living on longer trips. More interior room means better storage, seating, and privacy, which makes the drive and evenings much more pleasant. If we split the cost among more people, the per-person premium is small compared with the added comfort. Compared with the $850 van, the larger model likely has higher-quality finishes and systems, reducing minor hassles on the road. While the $700 option is cheap, its lack of bathroom and kitchen and required repairs create real risks. For a relaxed, flexible trip, the $1000 van is worth the extra investment.","brainstorm_alternative":[{"title":"Space and flexibility","description":"Argue that six beds allow hosting friends or accommodating larger families, making the $1000 van more versatile."},{"title":"Comfort on long trips","description":"Point out that the larger interior gives more living space, storage, and privacy for longer journeys."},{"title":"Value per person","description":"Explain that if you split the cost among more people, the per-person price difference becomes minimal and worth the upgrade."},{"title":"Higher build quality","description":"Suggest the fancier model may have better finishes and systems, reducing the chance of small annoyances on the road."},{"title":"Resale or future rental potential","description":"Note that a larger, well-equipped van could be easier to re-rent or sell later, so the up-front cost may pay off."}]}
|
| 5 |
+
{"id":25884,"task_type":"difficult_situation","task_description":"You were invited by a friend on an expensive family trip but are expected to help with chores during the trip, and your sister objects because it should be family time. Explain how you would handle this conflict, what you would say, and why.","custom_id":"25884__difficult_situation","rephrased_task":"You were invited by a friend to join their expensive family trip, but the friend expects you to help with chores while you are there. Your sister objects, saying the trip should be family time. Explain how you would handle this conflict. Address the person directly, outline the problem, state your decision, and give clear reasons for it.","brainstorm":[{"title":"Clarify expectations with the host","description":"Ask the friend exactly what chores are expected, how many hours, and when, so you can make an informed decision and avoid surprises."},{"title":"Propose a clear compromise","description":"Offer to help for a limited, defined time each day or with specific tasks only, and reserve evenings and at least one full day for family time."},{"title":"Offer a financial or practical contribution","description":"If chores are significant, propose contributing extra money to hire local help or cover costs instead of doing all the work yourself."},{"title":"Prioritize family boundaries","description":"Explain to your sister you value family time and will decline or limit participation if the arrangement interferes with family priorities."},{"title":"Use a partial attendance option","description":"Suggest attending only part of the trip so you can spend important family moments at home while keeping the friendship intact."}],"vocabulary":["clarify expectations","set boundaries","compromise","family time","contribute financially","unpaid chores","limited commitment","respectful decline","partial attendance","hire help","avoid resentment","mutual agreement","clear schedule","fairness"],"response":"Hi Sarah, I know you feel this should be family time and I respect that. I was invited by a friend to join their family trip, but they mentioned they expect me to help with some chores while I\u2019m there. Here\u2019s how I would handle it. First I would talk to our friend and clarify exactly what chores they expect and when, so there are no surprises. Then I would propose a compromise: I can help with light tasks for a limited amount of time each day or cover one morning, but I would keep evenings and at least one full day free for family activities. If they won\u2019t agree to reasonable limits, I would either attend only part of the trip or decline so I can protect our family time. That way I stay fair to both sides and avoid bitterness later.","response_alternative":"Hi Mark, thanks for inviting me. I appreciate the offer, but I need to be honest: I\u2019m not comfortable committing to a paid family trip where I\u2019m expected to do unpaid chores for most of the time. If you need help, I\u2019m willing to do one short shift a day or contribute an additional amount so you can hire local help, but I can\u2019t be the default helper for the whole vacation. If that is not acceptable, I will have to decline and prioritize family time at home. I hope you understand; I want to keep our friendship intact without feeling taken advantage of.","brainstorm_alternative":[{"title":"Decline the invitation","description":"Politely refuse if the host insists on unpaid chores that turn the paid trip into work, emphasizing your need to protect family time."},{"title":"Negotiate a paid or limited role","description":"Ask the host to either compensate you or limit your duties to a short, clearly scheduled slot each day."},{"title":"Suggest hiring help","description":"Recommend the host hire local help for cleaning or cooking so you can join without being responsible for chores."},{"title":"Propose an alternative gathering","description":"Suggest a separate friend-only activity where helping is reasonable, and keep the family trip strictly family."}]}
|
| 6 |
+
{"id":25884,"task_type":"expressing_opinions","task_description":"Do you think online shopping helps the environment or not? State your opinion and support it with reasons and examples.","custom_id":"25884__expressing_opinions","rephrased_task":"Do you think online shopping helps the environment or not? State your opinion and support it with reasons and examples.","brainstorm":[{"title":"Excess packaging","description":"Online orders often arrive with multiple boxes, plastic fillers and single-use materials, which increases landfill waste compared with buying in-store."},{"title":"Last-mile delivery emissions","description":"Individual home deliveries, especially same-day services, raise fuel use and emissions because drivers make many short stops rather than consolidated trips."},{"title":"High return rates","description":"Free and easy returns, especially for clothing, create extra transport and handling. Returned items may be reshipped, stored, or even thrown away."},{"title":"Warehouse energy and processing","description":"Large fulfillment centres consume substantial electricity for lighting, heating and cooling, and for automated sorting systems."},{"title":"Overconsumption driven by convenience","description":"Easy browsing and fast delivery encourage impulse buying, increasing the total number of items produced and shipped."}],"vocabulary":["carbon footprint","packaging waste","last-mile delivery","consolidated shipping","reverse logistics","fulfillment centre","return rate","delivery density","single-use plastics","inventory optimization","emissions per parcel","sustainable packaging","overconsumption","route optimization"],"response":"No, I do not think online shopping helps the environment overall. First, online orders generate a lot of packaging waste. Many items come wrapped in multiple boxes, plastic bags and filler material, and that adds up quickly when millions of parcels are shipped. Second, last-mile deliveries increase emissions. Same-day and individual home deliveries require vans to make many short stops, which is less fuel efficient than people combining errands. Third, return behaviour amplifies the problem. Clothing and shoes are often ordered in several sizes and many of those pieces are returned, requiring extra transport, restocking and sometimes disposal. For example, free return policies encourage multiple shipments for a single purchase, which multiplies the transport footprint. I acknowledge there are mitigations, such as consolidated delivery, reusable packaging and electric vans, but those solutions are not yet widespread enough. In conclusion, while online shopping can be convenient, as it currently operates it tends to increase waste and emissions unless companies and consumers adopt more sustainable practices.","response_alternative":"Yes, I think online shopping can help the environment overall, if done smartly. First, it reduces the number of personal car trips to stores. If one delivery truck replaces several customers driving to a mall, total vehicle kilometres can drop. Second, logistics allow for consolidated shipping and route optimization, which lowers emissions per parcel compared with many individuals each driving separately. Third, online retail can shrink the need for large physical stores, cutting heating, cooling and lighting energy use across many locations. Additionally, advanced inventory systems help reduce overstock and product waste. For instance, using data to match supply with demand prevents unnecessary production and disposal. There are downsides, like extra packaging and returns, but those can be managed with recyclable materials, parcel lockers and electric delivery fleets. Overall, with the right practices and infrastructure, online shopping has the potential to be greener than traditional retail.","brainstorm_alternative":[{"title":"Fewer individual car trips","description":"If customers avoid driving to stores and rely on consolidated deliveries, overall vehicle kilometres travelled can fall."},{"title":"Consolidated shipping efficiency","description":"Logistics companies can bundle many orders into a single truck route, lowering emissions per item compared with many separate shoppers driving."},{"title":"Smaller retail footprint","description":"Less demand for large brick-and-mortar stores can reduce energy use for heating, cooling and lighting retail space."},{"title":"Inventory optimization reduces waste","description":"Online retailers can use data to match supply to demand, lowering overstock and product waste compared with traditional retail."},{"title":"Cleaner delivery technologies","description":"Adoption of electric vans, cargo bikes and parcel lockers can cut last-mile emissions substantially."}]}
|
| 7 |
+
{"id":25884,"task_type":"unusual_situation","task_description":"Call a friend who works at another shop to report an unusual incident across the street: a man is standing near a coffee machine and has spilled coffee all over the floor and appears unsure what to do; describe what happened, its effects, and what you would do or recommend.","custom_id":"25884__unusual_situation","rephrased_task":"Call a friend who works at another shop and report an unusual incident across the street: a man is standing by a coffee machine and has spilled coffee all over the floor and appears unsure what to do. Describe what happened, the effects of the spill, and what you would do or recommend.","brainstorm":[{"title":"Give a clear opening","description":"Start by saying who you are and exactly where you are standing so your friend knows which location you mean."},{"title":"Describe the spill precisely","description":"Mention the size of the puddle, whether the coffee looks hot (steam), if the machine is still leaking, and any stained mats or furniture."},{"title":"Explain immediate effects","description":"Point out slip hazards, customers avoiding the area, possible burns, and any electrical risk from the machine."},{"title":"Recommend quick actions","description":"Suggest putting up a wet floor sign, mopping or soaking up the spill, unplugging the machine if it is leaking, and calling maintenance if needed."},{"title":"Offer help and follow up","description":"Offer to stay and assist, fetch cleaning supplies, or call a supervisor; ask your friend to call back with instructions."}],"vocabulary":["spill","puddle","steam","wet floor","mop","paper towels","wet floor sign","slip hazard","barista","coffee machine","drip tray","stain","electrical hazard","absorbent cloth"],"response":"Hi Sam, it\u2019s Alex. I\u2019m calling from across the street at the little coffee place next door. Something unusual just happened \u2014 a man at the self-serve coffee machine has knocked over his cup and there\u2019s a large puddle of coffee spreading across the tiled floor. He\u2019s just standing there holding a napkin, looking unsure what to do, and steam is still coming off the spill so it looks hot. Customers are stepping around it and someone nearly slipped, so it\u2019s a real slip hazard. The spill has soaked into the floor mat and stained the grout a bit. I would recommend putting up a wet floor sign, getting paper towels and a mop to soak up the mess, and unplugging the machine if it\u2019s leaking. I can wait and help if you want me to. Okay, let me know. Bye.","response_alternative":null,"brainstorm_alternative":null}
|
| 8 |
+
{"id":25880,"task_type":"giving_advice","task_description":"Your friend is starting a new job teaching a 12-year-old; give practical advice on how to teach the child effectively and explain why.","custom_id":"25880__giving_advice","rephrased_task":"Your friend is starting a new job teaching a twelve-year-old student. Give practical advice on how to teach the child effectively and explain why each suggestion will help.","brainstorm":[{"title":"Build rapport and set routines","description":"Start with a friendly icebreaker and establish clear daily routines so the student feels safe and knows what to expect; for example, a five-minute check-in and a visible daily agenda."},{"title":"Chunk lessons and set clear objectives","description":"Break lessons into short, focused segments with one clear goal each time so the child stays focused; for instance, two 15-minute activities instead of one long lecture."},{"title":"Use active, hands-on learning","description":"Include games, projects, or pair work to make abstract ideas concrete, such as a small experiment or role-play to practise skills."},{"title":"Differentiate instruction","description":"Adjust tasks by difficulty, allow choices, or provide scaffolds so the student can work at an appropriate level, for example offering extension questions and simplified prompts."},{"title":"Give frequent feedback and check understanding","description":"Use quick formative checks like exit tickets or thumbs-up responses and give specific praise to build confidence and guide your next lesson."}],"vocabulary":["build rapport","daily routines","learning objectives","chunking","scaffolding","differentiation","formative assessment","exit ticket","active learning","positive reinforcement","engagement strategies","behaviour expectations"],"response":"If I were you, start by building rapport and setting simple routines. Spend the first few minutes of each class on a quick check-in and display a visible agenda so the student knows what to expect; that reduces anxiety and helps them focus. Another thing you could do is chunk your lessons and make each segment have one clear objective. For a twelve-year-old, attention wanes quickly, so try two or three short activities with a summary at the end rather than a long lecture. Another thing you could do is use active, hands-on learning. Games, short projects, or pair work make abstract ideas concrete and increase motivation\u2014for example, use a mini experiment or a role-play to practise a skill. One more thing is to differentiate and scaffold tasks. Offer simpler prompts or extra hints when needed, and enrichment questions for when the student finishes early; this keeps the work appropriately challenging. One final thing is to check understanding often and give specific, positive feedback, like a quick exit ticket or targeted praise, so you can adjust lessons and build the student\u2019s confidence. Good luck\u2014consistent structure and engaging tasks will make your first weeks much smoother.","response_alternative":null,"brainstorm_alternative":null}
|
| 9 |
+
{"id":25880,"task_type":"personal_experience","task_description":"Describe your first encounter with a pet: where it happened, what occurred, and how you felt.","custom_id":"25880__personal_experience","rephrased_task":"Describe the first time you encountered a pet. Say where it happened, what occurred during the encounter, and how you felt.","brainstorm":[{"title":"Grandparent's pet","description":"A calm, welcoming animal at a relative's home \u2014 describe the setting, a small interaction like a lick or wag, and the feeling of warmth and curiosity."},{"title":"Stray rescued","description":"Finding a stray on the street or in a park, coaxing it with food, then calling a shelter or taking it home; emphasize surprise, compassion, and responsibility."},{"title":"Shelter visit","description":"First time inside an animal shelter, meeting many animals and connecting with one; highlight sensory details and the decision to adopt or foster."},{"title":"Friend's playful pet","description":"Visiting a friend and being introduced to their energetic dog or cat; focus on playful moments and initial nerves turning into laughter."},{"title":"Childhood classroom pet","description":"School hamster or class rabbit brought into class; describe the group interaction, holding the pet, and feeling responsible or proud."},{"title":"Unexpected encounter in nature","description":"Coming across a curious animal while hiking \u2014 a squirrel or duck \u2014 and the brief, surprising connection that left a strong impression."}],"vocabulary":["adopt","shelter","wagging tail","purring","timid","coax","curl up","bonded","leash","affectionate","groom","responsibility","nostalgic","belly rub"],"response":"My first encounter with a pet happened when I was seven at my grandparents\u2019 house. They had an old golden retriever who was always napping by the back door. I opened the door and felt a warm nose press against my hand, then he gave me a gentle, slobbery lick. At first I was nervous because I had never been close to a dog, but his calm eyes and slow tail wag made me relax. My grandmother showed me how to stroke him along his shoulders and offered him a piece of toast, which he eagerly accepted. He rolled onto his back for a belly rub and I laughed so hard I forgot to be scared. That small, affectionate welcome made me want a dog of my own and left me with a lasting sense of comfort and companionship.","response_alternative":null,"brainstorm_alternative":null}
|
| 10 |
+
{"id":25880,"task_type":"describing_scene","task_description":"This is an image of a busy park where people are playing, picnicking, and enjoying leisure activities, including someone playing with a dog, a girl practicing football tricks, and a person painting on a canvas.","custom_id":"25880__describing_scene","rephrased_task":"Describe what is happening in this illustration of a busy park. Include clear details about people playing, picnicking, someone playing with a dog, a girl practising football tricks, and a person painting on a canvas so the listener can picture the scene.","brainstorm":[{"title":"Overall setting","description":"Start with the time of day and general mood \u2014 sunny afternoon, lively but relaxed, with a wide grassy area and trees."},{"title":"Picnic groups","description":"Describe blankets, people sitting or reclining, sharing food, a cooler or picnic basket, and small children nearby."},{"title":"Dog and owner","description":"Portray the dog running or leaping to fetch a ball or frisbee and the owner throwing or calling, showing interaction and motion."},{"title":"Girl practising football tricks","description":"Mention the girl juggling or doing step-overs, focused expression, occasional applause or glances from passersby."},{"title":"Person painting","description":"Describe an easel, palette and brushstrokes, the painter sitting or standing under a tree, concentrating on a canvas."},{"title":"Background activity","description":"Add details like children on a playground, cyclists passing, benches with people reading, and trees or a small pond to complete the scene."}],"vocabulary":["picnic blanket","easel","palette","brushstrokes","juggling the ball","step-overs","fetch","leash","grassy lawn","sunlit","passersby","playground"],"response":"This is a lively park on a sunny afternoon, filled with people enjoying free time. In the foreground a family is sitting on a picnic blanket, sharing food from a basket while a couple of small children play nearby. To the left, someone is playing with a medium-sized dog, calling and throwing a ball so the dog runs and bounds after it. A few metres away a teenage girl is practising football tricks, rhythmically juggling the ball and doing quick step-overs while a couple of people watch from a nearby bench. Nearby under a tree an artist has set up an easel and is painting on canvas, carefully applying brushstrokes as they study the scene. In the background there are cyclists and children on a playground, which gives the whole park a relaxed, active atmosphere.","response_alternative":"I think a few things are likely to happen next. The dog will probably retrieve the ball a few more times and then flop down to rest, while the owner chats with someone on the blanket. The girl practising football tricks may pull off a neat move and get a small round of applause from the onlookers, which will encourage her to keep practising. The painter will likely step back to inspect the canvas, add a few final touches and then clean the brush. Nearby, children on the playground will change games or run toward their parents when it\u2019s time to leave, and one of the picnic groups might take out a camera or phone to capture a photo of the sunny afternoon.","brainstorm_alternative":null}
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "speaking-ideas-astro",
|
| 3 |
+
"version": "0.0.1",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "astro dev",
|
| 8 |
+
"build": "astro build",
|
| 9 |
+
"preview": "astro preview",
|
| 10 |
+
"check": "astro check"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"astro": "^4.10.0"
|
| 14 |
+
}
|
| 15 |
+
}
|
src/env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference path="../.astro/types.d.ts" />
|
src/layouts/BaseLayout.astro
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
import '../styles/global.css';
|
| 3 |
+
const { title = 'Speaking Practice Browser', description = 'Browse speaking and writing practice prompts with brainstorming help.' } = Astro.props;
|
| 4 |
+
---
|
| 5 |
+
<!DOCTYPE html>
|
| 6 |
+
<html lang="en">
|
| 7 |
+
<head>
|
| 8 |
+
<meta charset="utf-8" />
|
| 9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 10 |
+
<title>{title}</title>
|
| 11 |
+
<meta name="description" content={description} />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<slot />
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
src/pages/index.astro
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
| 3 |
+
import { loadSpeakingData, loadWritingData } from '../utils/loadData';
|
| 4 |
+
|
| 5 |
+
const ITEMS_PER_PAGE = 5;
|
| 6 |
+
|
| 7 |
+
const SPEAKING_TASK_MAPPING: Record<string, [number, string]> = {
|
| 8 |
+
giving_advice: [1, 'Giving Advice'],
|
| 9 |
+
personal_experience: [2, 'Talking About A Personal Experience'],
|
| 10 |
+
describing_scene: [3, 'Describing A Scene'],
|
| 11 |
+
making_predictions: [4, 'Making Predictions'],
|
| 12 |
+
comparing_persuading: [5, 'Comparing And Persuading'],
|
| 13 |
+
difficult_situation: [6, 'Dealing With A Difficult Situation'],
|
| 14 |
+
expressing_opinions: [7, 'Expressing Opinions'],
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
const WRITING_TASK_MAPPING: Record<string, [number, string]> = {
|
| 18 |
+
writing_email: [1, 'Writing An Email'],
|
| 19 |
+
responding_survey: [2, 'Responding To Survey Questions'],
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const [speakingRecords, writingRecords] = await Promise.all([
|
| 23 |
+
loadSpeakingData(),
|
| 24 |
+
loadWritingData(),
|
| 25 |
+
]);
|
| 26 |
+
|
| 27 |
+
const recordsByCategory: Record<string, any[]> = {
|
| 28 |
+
Speaking: speakingRecords,
|
| 29 |
+
Writing: writingRecords,
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const categories = [
|
| 33 |
+
{ id: 'Speaking', label: 'Speaking', mapping: SPEAKING_TASK_MAPPING, records: speakingRecords },
|
| 34 |
+
{ id: 'Writing', label: 'Writing', mapping: WRITING_TASK_MAPPING, records: writingRecords },
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
const availableCategories = categories.filter((category) => category.records.length > 0);
|
| 38 |
+
const defaultCategory = (availableCategories[0] ?? categories[0]).id;
|
| 39 |
+
const defaultRecords = recordsByCategory[defaultCategory] ?? [];
|
| 40 |
+
|
| 41 |
+
const totalItems = defaultRecords.length;
|
| 42 |
+
const totalPages = totalItems > 0 ? Math.ceil(totalItems / ITEMS_PER_PAGE) : 0;
|
| 43 |
+
const firstPageRecords = totalItems > 0 ? defaultRecords.slice(0, ITEMS_PER_PAGE) : [];
|
| 44 |
+
|
| 45 |
+
const escapeHtml = (value: string | undefined | null) =>
|
| 46 |
+
String(value ?? '')
|
| 47 |
+
.replace(/&/g, '&')
|
| 48 |
+
.replace(/</g, '<')
|
| 49 |
+
.replace(/>/g, '>');
|
| 50 |
+
|
| 51 |
+
const formatMultiline = (value: string | undefined | null) =>
|
| 52 |
+
escapeHtml(value).replace(/\n/g, '<br />');
|
| 53 |
+
|
| 54 |
+
const renderIdeas = (ideas: any[] | undefined | null) => {
|
| 55 |
+
if (!ideas || ideas.length === 0) return '';
|
| 56 |
+
const items = ideas
|
| 57 |
+
.map((idea) => {
|
| 58 |
+
const title = escapeHtml(idea?.title ?? 'Idea');
|
| 59 |
+
const description = escapeHtml(idea?.description ?? '');
|
| 60 |
+
return `<li><strong>${title}:</strong> ${description}</li>`;
|
| 61 |
+
})
|
| 62 |
+
.join('');
|
| 63 |
+
return `<ul>${items}</ul>`;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const renderVocabulary = (words: string[] | undefined | null) => {
|
| 67 |
+
if (!words || words.length === 0) return '';
|
| 68 |
+
const items = words.map((word) => `<li>${escapeHtml(word)}</li>`).join('');
|
| 69 |
+
return `<ul>${items}</ul>`;
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const buildCard = (record: Record<string, any>) => {
|
| 73 |
+
const recordId = record?.id ?? 'N/A';
|
| 74 |
+
const taskType = escapeHtml((record?.task_type ?? '').replace(/_/g, ' ')).replace(/\b\w/g, (char) => char.toUpperCase());
|
| 75 |
+
|
| 76 |
+
const sections: string[] = [];
|
| 77 |
+
|
| 78 |
+
if (record?.rephrased_task) {
|
| 79 |
+
sections.push(`
|
| 80 |
+
<section class="card-section">
|
| 81 |
+
<h4>Description</h4>
|
| 82 |
+
<p>${formatMultiline(record.rephrased_task)}</p>
|
| 83 |
+
</section>
|
| 84 |
+
`);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (record?.brainstorm) {
|
| 88 |
+
sections.push(`
|
| 89 |
+
<section class="card-section">
|
| 90 |
+
<h4>Brainstorming Ideas</h4>
|
| 91 |
+
${renderIdeas(record.brainstorm)}
|
| 92 |
+
</section>
|
| 93 |
+
`);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
if (record?.brainstorm_alternative) {
|
| 97 |
+
sections.push(`
|
| 98 |
+
<section class="card-section">
|
| 99 |
+
<h4>Alternative Ideas</h4>
|
| 100 |
+
${renderIdeas(record.brainstorm_alternative)}
|
| 101 |
+
</section>
|
| 102 |
+
`);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if (record?.vocabulary) {
|
| 106 |
+
sections.push(`
|
| 107 |
+
<section class="card-section">
|
| 108 |
+
<h4>Vocabulary</h4>
|
| 109 |
+
${renderVocabulary(record.vocabulary)}
|
| 110 |
+
</section>
|
| 111 |
+
`);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (record?.response) {
|
| 115 |
+
sections.push(`
|
| 116 |
+
<section class="card-section">
|
| 117 |
+
<h4>Sample Response</h4>
|
| 118 |
+
<p>${formatMultiline(record.response)}</p>
|
| 119 |
+
</section>
|
| 120 |
+
`);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (record?.response_alternative) {
|
| 124 |
+
sections.push(`
|
| 125 |
+
<section class="card-section">
|
| 126 |
+
<h4>Alternative Response</h4>
|
| 127 |
+
<p>${formatMultiline(record.response_alternative)}</p>
|
| 128 |
+
</section>
|
| 129 |
+
`);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return `
|
| 133 |
+
<article class="card">
|
| 134 |
+
<h3>Task ${recordId} - ${taskType}</h3>
|
| 135 |
+
${sections.join('\n')}
|
| 136 |
+
</article>
|
| 137 |
+
`;
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const initialCardsHtml = firstPageRecords.map(buildCard).join('');
|
| 141 |
+
|
| 142 |
+
const initialSummary = totalItems === 0
|
| 143 |
+
? 'No records available yet for this category.'
|
| 144 |
+
: `Showing 1-${Math.min(ITEMS_PER_PAGE, totalItems)} of ${totalItems} records | Page 1 of ${totalPages}`;
|
| 145 |
+
|
| 146 |
+
const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPages}`;
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
<BaseLayout title="Speaking Practice Browser">
|
| 150 |
+
<main>
|
| 151 |
+
<header class="app-header">
|
| 152 |
+
<h1>Speaking Practice Browser</h1>
|
| 153 |
+
<p>
|
| 154 |
+
Browse CELPIP-style speaking and writing tasks with brainstorming prompts, curated vocabulary, and
|
| 155 |
+
sample responses. Use the filters to find exactly the practice you need.
|
| 156 |
+
</p>
|
| 157 |
+
</header>
|
| 158 |
+
|
| 159 |
+
<section class="control-panel" aria-label="Filters and options">
|
| 160 |
+
<h2>Filters & Options</h2>
|
| 161 |
+
|
| 162 |
+
<div class="field">
|
| 163 |
+
<span class="field-label">Category</span>
|
| 164 |
+
<div class="radio-group" role="radiogroup" aria-label="Category selector">
|
| 165 |
+
{categories.map((category) => (
|
| 166 |
+
<label class="radio-pill">
|
| 167 |
+
<input
|
| 168 |
+
type="radio"
|
| 169 |
+
name="category"
|
| 170 |
+
value={category.id}
|
| 171 |
+
checked={category.id === defaultCategory}
|
| 172 |
+
/>
|
| 173 |
+
<span>{category.label}</span>
|
| 174 |
+
</label>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div class="field">
|
| 180 |
+
<label class="field-label" for="task-filter">Task Type</label>
|
| 181 |
+
<select id="task-filter">
|
| 182 |
+
<option value="all">All Tasks</option>
|
| 183 |
+
{(Object.entries(categories.find((c) => c.id === defaultCategory)?.mapping ?? {})).map(([taskId, [num, name]]) => (
|
| 184 |
+
<option value={taskId}>Task {num} - {name}</option>
|
| 185 |
+
))}
|
| 186 |
+
</select>
|
| 187 |
+
</div>
|
| 188 |
+
</section>
|
| 189 |
+
|
| 190 |
+
<section class="content-area" aria-live="polite">
|
| 191 |
+
<p class="summary" id="summary-text">{initialSummary}</p>
|
| 192 |
+
<div class="cards" id="cards-container" set:html={initialCardsHtml}></div>
|
| 193 |
+
</section>
|
| 194 |
+
|
| 195 |
+
<nav class="pagination-panel" aria-label="Pagination controls">
|
| 196 |
+
<button id="prev-btn" type="button">Previous</button>
|
| 197 |
+
<div class="page-info" id="page-info">{initialPageInfo}</div>
|
| 198 |
+
<button id="next-btn" type="button">Next</button>
|
| 199 |
+
</nav>
|
| 200 |
+
</main>
|
| 201 |
+
|
| 202 |
+
<script define:vars={{ speakingRecords, writingRecords, defaultCategory }} type="module">
|
| 203 |
+
// Data embedded directly from server
|
| 204 |
+
const appData = {
|
| 205 |
+
records: {
|
| 206 |
+
Speaking: speakingRecords,
|
| 207 |
+
Writing: writingRecords
|
| 208 |
+
},
|
| 209 |
+
taskMappings: {
|
| 210 |
+
Speaking: [
|
| 211 |
+
{id: 'giving_advice', number: 1, label: 'Giving Advice', display: 'Task 1 - Giving Advice'},
|
| 212 |
+
{id: 'personal_experience', number: 2, label: 'Talking About A Personal Experience', display: 'Task 2 - Talking About A Personal Experience'},
|
| 213 |
+
{id: 'describing_scene', number: 3, label: 'Describing A Scene', display: 'Task 3 - Describing A Scene'},
|
| 214 |
+
{id: 'making_predictions', number: 4, label: 'Making Predictions', display: 'Task 4 - Making Predictions'},
|
| 215 |
+
{id: 'comparing_persuading', number: 5, label: 'Comparing And Persuading', display: 'Task 5 - Comparing And Persuading'},
|
| 216 |
+
{id: 'difficult_situation', number: 6, label: 'Dealing With A Difficult Situation', display: 'Task 6 - Dealing With A Difficult Situation'},
|
| 217 |
+
{id: 'expressing_opinions', number: 7, label: 'Expressing Opinions', display: 'Task 7 - Expressing Opinions'}
|
| 218 |
+
],
|
| 219 |
+
Writing: [
|
| 220 |
+
{id: 'writing_email', number: 1, label: 'Writing An Email', display: 'Task 1 - Writing An Email'},
|
| 221 |
+
{id: 'responding_survey', number: 2, label: 'Responding To Survey Questions', display: 'Task 2 - Responding To Survey Questions'}
|
| 222 |
+
]
|
| 223 |
+
},
|
| 224 |
+
categories: [
|
| 225 |
+
{id: 'Speaking', label: 'Speaking'},
|
| 226 |
+
{id: 'Writing', label: 'Writing'}
|
| 227 |
+
],
|
| 228 |
+
itemsPerPage: 5,
|
| 229 |
+
defaultCategory: defaultCategory
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
const state = {
|
| 233 |
+
category: appData.defaultCategory,
|
| 234 |
+
taskType: 'all',
|
| 235 |
+
page: 0,
|
| 236 |
+
totalPages: 0
|
| 237 |
+
};
|
| 238 |
+
|
| 239 |
+
const recordsByCategory = appData.records;
|
| 240 |
+
const taskMappings = appData.taskMappings;
|
| 241 |
+
const itemsPerPage = appData.itemsPerPage;
|
| 242 |
+
|
| 243 |
+
const cardsContainer = document.getElementById('cards-container');
|
| 244 |
+
const summaryText = document.getElementById('summary-text');
|
| 245 |
+
const pageInfo = document.getElementById('page-info');
|
| 246 |
+
const prevBtn = document.getElementById('prev-btn');
|
| 247 |
+
const nextBtn = document.getElementById('next-btn');
|
| 248 |
+
const taskFilter = document.getElementById('task-filter');
|
| 249 |
+
const categoryInputs = Array.from(document.querySelectorAll('input[name="category"]'));
|
| 250 |
+
|
| 251 |
+
const escapeHtml = (value) =>
|
| 252 |
+
String(value ?? '')
|
| 253 |
+
.replace(/&/g, '&')
|
| 254 |
+
.replace(/</g, '<')
|
| 255 |
+
.replace(/>/g, '>');
|
| 256 |
+
|
| 257 |
+
const formatMultiline = (value) => escapeHtml(value).replace(/\n/g, '<br />');
|
| 258 |
+
|
| 259 |
+
const renderList = (items, formatter) => {
|
| 260 |
+
if (!items || items.length === 0) return '';
|
| 261 |
+
return `<ul>${items.map(formatter).join('')}</ul>`;
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
const buildCard = (record) => {
|
| 265 |
+
const recordId = record?.id ?? 'N/A';
|
| 266 |
+
const taskType = escapeHtml(String(record?.task_type ?? ''))
|
| 267 |
+
.replace(/_/g, ' ')
|
| 268 |
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
| 269 |
+
|
| 270 |
+
const sections = [];
|
| 271 |
+
|
| 272 |
+
if (record?.rephrased_task) {
|
| 273 |
+
sections.push(`
|
| 274 |
+
<section class="card-section">
|
| 275 |
+
<h4>Description</h4>
|
| 276 |
+
<p>${formatMultiline(record.rephrased_task)}</p>
|
| 277 |
+
</section>
|
| 278 |
+
`);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
if (Array.isArray(record?.brainstorm) && record.brainstorm.length) {
|
| 282 |
+
sections.push(`
|
| 283 |
+
<section class="card-section">
|
| 284 |
+
<h4>Brainstorming Ideas</h4>
|
| 285 |
+
${renderList(record.brainstorm, (idea) => {
|
| 286 |
+
const title = escapeHtml(idea?.title ?? 'Idea');
|
| 287 |
+
const description = escapeHtml(idea?.description ?? '');
|
| 288 |
+
return `<li><strong>${title}:</strong> ${description}</li>`;
|
| 289 |
+
})}
|
| 290 |
+
</section>
|
| 291 |
+
`);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
if (Array.isArray(record?.brainstorm_alternative) && record.brainstorm_alternative.length) {
|
| 295 |
+
sections.push(`
|
| 296 |
+
<section class="card-section">
|
| 297 |
+
<h4>Alternative Ideas</h4>
|
| 298 |
+
${renderList(record.brainstorm_alternative, (idea) => {
|
| 299 |
+
const title = escapeHtml(idea?.title ?? 'Idea');
|
| 300 |
+
const description = escapeHtml(idea?.description ?? '');
|
| 301 |
+
return `<li><strong>${title}:</strong> ${description}</li>`;
|
| 302 |
+
})}
|
| 303 |
+
</section>
|
| 304 |
+
`);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
if (Array.isArray(record?.vocabulary) && record.vocabulary.length) {
|
| 308 |
+
sections.push(`
|
| 309 |
+
<section class="card-section">
|
| 310 |
+
<h4>Vocabulary</h4>
|
| 311 |
+
${renderList(record.vocabulary, (word) => `<li>${escapeHtml(word)}</li>`)}
|
| 312 |
+
</section>
|
| 313 |
+
`);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
if (record?.response) {
|
| 317 |
+
sections.push(`
|
| 318 |
+
<section class="card-section">
|
| 319 |
+
<h4>Sample Response</h4>
|
| 320 |
+
<p>${formatMultiline(record.response)}</p>
|
| 321 |
+
</section>
|
| 322 |
+
`);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
if (record?.response_alternative) {
|
| 326 |
+
sections.push(`
|
| 327 |
+
<section class="card-section">
|
| 328 |
+
<h4>Alternative Response</h4>
|
| 329 |
+
<p>${formatMultiline(record.response_alternative)}</p>
|
| 330 |
+
</section>
|
| 331 |
+
`);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
return `
|
| 335 |
+
<article class="card">
|
| 336 |
+
<h3>Task ${recordId} - ${taskType}</h3>
|
| 337 |
+
${sections.join('\n')}
|
| 338 |
+
</article>
|
| 339 |
+
`;
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
const applyState = () => {
|
| 343 |
+
const allRecords = recordsByCategory[state.category] ?? [];
|
| 344 |
+
const filtered = state.taskType === 'all'
|
| 345 |
+
? allRecords
|
| 346 |
+
: allRecords.filter((item) => item?.task_type === state.taskType);
|
| 347 |
+
|
| 348 |
+
const totalItems = filtered.length;
|
| 349 |
+
state.totalPages = totalItems === 0 ? 0 : Math.ceil(totalItems / itemsPerPage);
|
| 350 |
+
state.page = Math.min(state.page, Math.max(state.totalPages - 1, 0));
|
| 351 |
+
|
| 352 |
+
const start = state.page * itemsPerPage;
|
| 353 |
+
const end = start + itemsPerPage;
|
| 354 |
+
const pageItems = filtered.slice(start, end);
|
| 355 |
+
|
| 356 |
+
if (cardsContainer) {
|
| 357 |
+
cardsContainer.innerHTML = pageItems.map(buildCard).join('');
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if (totalItems === 0) {
|
| 361 |
+
const hasRecordsInCategory = allRecords.length > 0;
|
| 362 |
+
if (summaryText) {
|
| 363 |
+
if (hasRecordsInCategory) {
|
| 364 |
+
summaryText.textContent = 'No records found for the selected task type.';
|
| 365 |
+
} else {
|
| 366 |
+
if (state.category === 'Writing') {
|
| 367 |
+
summaryText.textContent = 'No writing tasks available yet. Writing data will be displayed here when available.';
|
| 368 |
+
} else {
|
| 369 |
+
summaryText.textContent = 'No records available for this category.';
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
if (pageInfo) pageInfo.textContent = 'Page 0 of 0';
|
| 374 |
+
} else {
|
| 375 |
+
const startNum = start + 1;
|
| 376 |
+
const endNum = Math.min(end, totalItems);
|
| 377 |
+
if (summaryText) summaryText.textContent = `Showing ${startNum}-${endNum} of ${totalItems} records | Page ${state.page + 1} of ${state.totalPages}`;
|
| 378 |
+
if (pageInfo) pageInfo.textContent = `Page ${state.page + 1} of ${state.totalPages}`;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
if (prevBtn) prevBtn.disabled = state.page <= 0;
|
| 382 |
+
if (nextBtn) nextBtn.disabled = state.totalPages === 0 || state.page >= state.totalPages - 1;
|
| 383 |
+
};
|
| 384 |
+
|
| 385 |
+
const updateTaskOptions = () => {
|
| 386 |
+
const options = taskMappings[state.category] ?? [];
|
| 387 |
+
if (!taskFilter) return;
|
| 388 |
+
|
| 389 |
+
const currentValue = taskFilter.value;
|
| 390 |
+
taskFilter.innerHTML = '<option value="all">All Tasks</option>' +
|
| 391 |
+
options
|
| 392 |
+
.map((task) => `<option value="${task.id}">${task.display}</option>`)
|
| 393 |
+
.join('');
|
| 394 |
+
|
| 395 |
+
const hasRecords = (recordsByCategory[state.category] ?? []).length > 0;
|
| 396 |
+
|
| 397 |
+
if (hasRecords && options.some((task) => task.id === currentValue)) {
|
| 398 |
+
taskFilter.value = currentValue;
|
| 399 |
+
} else {
|
| 400 |
+
taskFilter.value = 'all';
|
| 401 |
+
state.taskType = 'all';
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
taskFilter.disabled = !hasRecords;
|
| 405 |
+
};
|
| 406 |
+
|
| 407 |
+
// Initialize
|
| 408 |
+
updateTaskOptions();
|
| 409 |
+
applyState();
|
| 410 |
+
|
| 411 |
+
// Event listeners
|
| 412 |
+
categoryInputs.forEach((input) => {
|
| 413 |
+
input.addEventListener('change', (event) => {
|
| 414 |
+
const target = event.target;
|
| 415 |
+
if (!(target instanceof HTMLInputElement)) return;
|
| 416 |
+
state.category = target.value;
|
| 417 |
+
state.page = 0;
|
| 418 |
+
state.taskType = 'all';
|
| 419 |
+
updateTaskOptions();
|
| 420 |
+
applyState();
|
| 421 |
+
});
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
if (taskFilter) {
|
| 425 |
+
taskFilter.addEventListener('change', (event) => {
|
| 426 |
+
const target = event.target;
|
| 427 |
+
if (!(target instanceof HTMLSelectElement)) return;
|
| 428 |
+
state.taskType = target.value;
|
| 429 |
+
state.page = 0;
|
| 430 |
+
applyState();
|
| 431 |
+
});
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
if (prevBtn) {
|
| 435 |
+
prevBtn.addEventListener('click', () => {
|
| 436 |
+
state.page = Math.max(0, state.page - 1);
|
| 437 |
+
applyState();
|
| 438 |
+
});
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
if (nextBtn) {
|
| 442 |
+
nextBtn.addEventListener('click', () => {
|
| 443 |
+
state.page = Math.min(state.totalPages - 1, state.page + 1);
|
| 444 |
+
applyState();
|
| 445 |
+
});
|
| 446 |
+
}
|
| 447 |
+
</script>
|
| 448 |
+
</BaseLayout>
|
src/styles/global.css
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: dark;
|
| 3 |
+
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
| 4 |
+
background-color: #0d1117;
|
| 5 |
+
color: #f0f6fc;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
body {
|
| 9 |
+
margin: 0;
|
| 10 |
+
min-height: 100vh;
|
| 11 |
+
background: radial-gradient(circle at top, rgba(88, 166, 255, 0.08), transparent 55%), #0d1117;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
main {
|
| 15 |
+
max-width: 1040px;
|
| 16 |
+
margin: 0 auto;
|
| 17 |
+
padding: clamp(16px, 5vw, 40px);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
h1,
|
| 21 |
+
h2,
|
| 22 |
+
h3,
|
| 23 |
+
h4 {
|
| 24 |
+
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
| 25 |
+
color: #f0f6fc;
|
| 26 |
+
margin: 0;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
p {
|
| 30 |
+
margin: 0;
|
| 31 |
+
color: #c9d1d9;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.app-header {
|
| 35 |
+
margin-bottom: clamp(24px, 5vw, 40px);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.app-header h1 {
|
| 39 |
+
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
| 40 |
+
display: flex;
|
| 41 |
+
align-items: center;
|
| 42 |
+
gap: 0.6rem;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.app-header h1 span {
|
| 46 |
+
font-size: 1.8rem;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.app-header p {
|
| 50 |
+
margin-top: 10px;
|
| 51 |
+
max-width: 640px;
|
| 52 |
+
line-height: 1.7;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.control-panel,
|
| 56 |
+
.pagination-panel {
|
| 57 |
+
background: rgba(22, 27, 34, 0.92);
|
| 58 |
+
border: 1px solid #30363d;
|
| 59 |
+
border-radius: 14px;
|
| 60 |
+
padding: clamp(16px, 4vw, 24px);
|
| 61 |
+
margin-bottom: clamp(20px, 4vw, 32px);
|
| 62 |
+
box-shadow: 0 10px 30px rgba(2, 12, 27, 0.35);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.control-panel h2 {
|
| 66 |
+
font-size: 1.1rem;
|
| 67 |
+
margin-bottom: 16px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.field {
|
| 71 |
+
display: flex;
|
| 72 |
+
flex-direction: column;
|
| 73 |
+
gap: 12px;
|
| 74 |
+
margin-bottom: 18px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.field:last-of-type {
|
| 78 |
+
margin-bottom: 0;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.field-label {
|
| 82 |
+
font-weight: 600;
|
| 83 |
+
color: #9ebdf2;
|
| 84 |
+
text-transform: uppercase;
|
| 85 |
+
font-size: 0.75rem;
|
| 86 |
+
letter-spacing: 0.08em;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.radio-group,
|
| 90 |
+
.per-page-group {
|
| 91 |
+
display: flex;
|
| 92 |
+
flex-wrap: wrap;
|
| 93 |
+
gap: 10px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.radio-pill input,
|
| 97 |
+
.per-page-option input {
|
| 98 |
+
display: none;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.radio-pill span,
|
| 102 |
+
.per-page-option span {
|
| 103 |
+
display: inline-flex;
|
| 104 |
+
align-items: center;
|
| 105 |
+
justify-content: center;
|
| 106 |
+
padding: 10px 16px;
|
| 107 |
+
border-radius: 30px;
|
| 108 |
+
border: 1px solid #30363d;
|
| 109 |
+
background: rgba(15, 18, 25, 0.85);
|
| 110 |
+
color: #c9d1d9;
|
| 111 |
+
font-size: 0.9rem;
|
| 112 |
+
cursor: pointer;
|
| 113 |
+
transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.radio-pill input:checked + span,
|
| 117 |
+
.per-page-option input:checked + span {
|
| 118 |
+
border-color: #1f6feb;
|
| 119 |
+
color: #fff;
|
| 120 |
+
background: #1f6feb;
|
| 121 |
+
box-shadow: 0 6px 18px rgba(31, 111, 235, 0.28);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
select,
|
| 125 |
+
button,
|
| 126 |
+
textarea {
|
| 127 |
+
font-family: inherit;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
select {
|
| 131 |
+
padding: 10px 12px;
|
| 132 |
+
border-radius: 10px;
|
| 133 |
+
border: 1px solid #30363d;
|
| 134 |
+
background: rgba(15, 18, 25, 0.85);
|
| 135 |
+
color: #f0f6fc;
|
| 136 |
+
font-size: 0.95rem;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.cards {
|
| 140 |
+
display: grid;
|
| 141 |
+
gap: clamp(16px, 3vw, 24px);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.card {
|
| 145 |
+
border: 1px solid rgba(48, 54, 61, 0.85);
|
| 146 |
+
border-radius: 14px;
|
| 147 |
+
padding: clamp(18px, 3vw, 28px);
|
| 148 |
+
background: rgba(13, 17, 23, 0.92);
|
| 149 |
+
box-shadow: 0 18px 32px rgba(2, 12, 27, 0.45);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.card h3 {
|
| 153 |
+
font-size: 1.15rem;
|
| 154 |
+
color: #58a6ff;
|
| 155 |
+
margin-bottom: 14px;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.card-section {
|
| 159 |
+
border-top: 1px solid rgba(48, 54, 61, 0.7);
|
| 160 |
+
padding-top: 14px;
|
| 161 |
+
margin-top: 18px;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.card-section:first-of-type {
|
| 165 |
+
border-top: none;
|
| 166 |
+
padding-top: 0;
|
| 167 |
+
margin-top: 0;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.card-section h4 {
|
| 171 |
+
font-size: 0.95rem;
|
| 172 |
+
color: #79c0ff;
|
| 173 |
+
margin-bottom: 8px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.card-section ul {
|
| 177 |
+
margin: 0;
|
| 178 |
+
padding-left: 20px;
|
| 179 |
+
color: #c9d1d9;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.card-section li {
|
| 183 |
+
margin-bottom: 6px;
|
| 184 |
+
line-height: 1.6;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.card-section p {
|
| 188 |
+
margin: 0;
|
| 189 |
+
line-height: 1.7;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.summary {
|
| 193 |
+
margin-bottom: clamp(16px, 3vw, 24px);
|
| 194 |
+
font-size: 0.95rem;
|
| 195 |
+
color: #9ebdf2;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.pagination-panel {
|
| 199 |
+
display: flex;
|
| 200 |
+
align-items: center;
|
| 201 |
+
justify-content: space-between;
|
| 202 |
+
gap: 16px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.pagination-panel button {
|
| 206 |
+
flex: none;
|
| 207 |
+
border: 1px solid #30363d;
|
| 208 |
+
background: rgba(15, 18, 25, 0.9);
|
| 209 |
+
color: #f0f6fc;
|
| 210 |
+
padding: 10px 20px;
|
| 211 |
+
border-radius: 10px;
|
| 212 |
+
font-weight: 600;
|
| 213 |
+
cursor: pointer;
|
| 214 |
+
transition: background 0.2s ease, border 0.2s ease;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.pagination-panel button:hover:not([disabled]) {
|
| 218 |
+
border-color: #1f6feb;
|
| 219 |
+
color: #fff;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.pagination-panel button[disabled] {
|
| 223 |
+
opacity: 0.45;
|
| 224 |
+
cursor: not-allowed;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.page-info {
|
| 228 |
+
flex: 1;
|
| 229 |
+
text-align: center;
|
| 230 |
+
font-size: 0.95rem;
|
| 231 |
+
color: #c9d1d9;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
@media (max-width: 720px) {
|
| 235 |
+
.pagination-panel {
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
align-items: stretch;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.pagination-panel button {
|
| 241 |
+
width: 100%;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.page-info {
|
| 245 |
+
width: 100%;
|
| 246 |
+
text-align: center;
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
@media (max-width: 540px) {
|
| 251 |
+
main {
|
| 252 |
+
padding: clamp(14px, 6vw, 28px);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.control-panel,
|
| 256 |
+
.pagination-panel {
|
| 257 |
+
padding: clamp(14px, 6vw, 20px);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.radio-group,
|
| 261 |
+
.per-page-group {
|
| 262 |
+
gap: 8px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.radio-pill span,
|
| 266 |
+
.per-page-option span {
|
| 267 |
+
width: 100%;
|
| 268 |
+
justify-content: center;
|
| 269 |
+
}
|
| 270 |
+
}
|
src/utils/loadData.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile } from 'node:fs/promises';
|
| 2 |
+
import { fileURLToPath } from 'node:url';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
|
| 5 |
+
export type SpeakingRecord = Record<string, unknown> & {
|
| 6 |
+
id: number | string;
|
| 7 |
+
task_type: string;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const dataDir = fileURLToPath(new URL('../../data/', import.meta.url));
|
| 11 |
+
|
| 12 |
+
async function loadJsonl<T = Record<string, unknown>>(fileName: string): Promise<T[]> {
|
| 13 |
+
const filePath = path.join(dataDir, fileName);
|
| 14 |
+
try {
|
| 15 |
+
const content = await readFile(filePath, 'utf-8');
|
| 16 |
+
return content
|
| 17 |
+
.split(/\r?\n/)
|
| 18 |
+
.filter((line) => line.trim().length > 0)
|
| 19 |
+
.map((line) => JSON.parse(line) as T);
|
| 20 |
+
} catch (error) {
|
| 21 |
+
console.warn(`Warning: unable to load ${fileName}.`, error instanceof Error ? error.message : error);
|
| 22 |
+
return [];
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export async function loadSpeakingData(): Promise<SpeakingRecord[]> {
|
| 27 |
+
return loadJsonl<SpeakingRecord>('speaking.jsonl');
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export async function loadWritingData(): Promise<SpeakingRecord[]> {
|
| 31 |
+
return loadJsonl<SpeakingRecord>('writing.jsonl');
|
| 32 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "astro/tsconfigs/strict",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"baseUrl": "."
|
| 5 |
+
}
|
| 6 |
+
}
|